Benoit Marty
2 years ago
64 changed files with 3189 additions and 33 deletions
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
package io.element.android.x.initializer |
||||
|
||||
import android.content.Context |
||||
import androidx.startup.Initializer |
||||
import io.element.android.x.features.rageshake.crash.VectorUncaughtExceptionHandler |
||||
|
||||
class CrashInitializer : Initializer<Unit> { |
||||
|
||||
override fun create(context: Context) { |
||||
VectorUncaughtExceptionHandler(context).activate() |
||||
} |
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList() |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
plugins { |
||||
id("io.element.android-compose-library") |
||||
alias(libs.plugins.ksp) |
||||
alias(libs.plugins.anvil) |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.x.features.preferences" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(project(":anvilannotations")) |
||||
anvil(project(":anvilcodegen")) |
||||
implementation(project(":libraries:di")) |
||||
implementation(project(":libraries:core")) |
||||
implementation(project(":features:rageshake")) |
||||
implementation(project(":libraries:designsystem")) |
||||
implementation(project(":libraries:elementresources")) |
||||
implementation(libs.mavericks.compose) |
||||
implementation(libs.datetime) |
||||
implementation(libs.accompanist.placeholder) |
||||
testImplementation(libs.test.junit) |
||||
androidTestImplementation(libs.test.junitext) |
||||
ksp(libs.showkase.processor) |
||||
} |
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here. |
||||
# You can control the set of applied configuration files using the |
||||
# proguardFiles setting in build.gradle. |
||||
# |
||||
# For more details, see |
||||
# http://developer.android.com/guide/developing/tools/proguard.html |
||||
|
||||
# If your project uses WebView with JS, uncomment the following |
||||
# and specify the fully qualified class name to the JavaScript interface |
||||
# class: |
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { |
||||
# public *; |
||||
#} |
||||
|
||||
# Uncomment this to preserve the line number information for |
||||
# debugging stack traces. |
||||
#-keepattributes SourceFile,LineNumberTable |
||||
|
||||
# If you keep the line number information, uncomment this to |
||||
# hide the original source file name. |
||||
#-renamesourcefileattribute SourceFile |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
package io.element.android.x.features.preferences |
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4 |
||||
import androidx.test.platform.app.InstrumentationRegistry |
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Test |
||||
import org.junit.runner.RunWith |
||||
|
||||
/** |
||||
* Instrumented test, which will execute on an Android device. |
||||
* |
||||
* See [testing documentation](http://d.android.com/tools/testing). |
||||
*/ |
||||
@RunWith(AndroidJUnit4::class) |
||||
class ExampleInstrumentedTest { |
||||
@Test |
||||
fun useAppContext() { |
||||
// Context of the app under test. |
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext |
||||
assertEquals("io.element.android.x.features.preferences.test", appContext.packageName) |
||||
} |
||||
} |
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest> |
||||
|
||||
</manifest> |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class) |
||||
|
||||
package io.element.android.x.features.preferences |
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import io.element.android.x.designsystem.components.preferences.PreferenceScreen |
||||
import io.element.android.x.features.rageshake.preferences.RageshakePreferenceCategory |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun PreferencesScreen( |
||||
onBackPressed: () -> Unit = {}, |
||||
) { |
||||
// TODO Hierarchy! |
||||
// TODO Move logout here |
||||
// Include pref from other modules |
||||
PreferencesContent(onBackPressed = onBackPressed) |
||||
} |
||||
|
||||
@Composable |
||||
fun PreferencesContent( |
||||
modifier: Modifier = Modifier, |
||||
onBackPressed: () -> Unit = {}, |
||||
) { |
||||
PreferenceScreen( |
||||
modifier = modifier, |
||||
onBackPressed = onBackPressed, |
||||
title = stringResource(id = ElementR.string.settings) |
||||
) { |
||||
RageshakePreferenceCategory() |
||||
} |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
fun PreferencesContentPreview() { |
||||
PreferencesContent() |
||||
} |
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
package io.element.android.x.features.preferences |
||||
|
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Test |
||||
|
||||
/** |
||||
* Example local unit test, which will execute on the development machine (host). |
||||
* |
||||
* See [testing documentation](http://d.android.com/tools/testing). |
||||
*/ |
||||
class ExampleUnitTest { |
||||
@Test |
||||
fun addition_isCorrect() { |
||||
assertEquals(4, 2 + 2) |
||||
} |
||||
} |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
plugins { |
||||
id("io.element.android-compose-library") |
||||
alias(libs.plugins.ksp) |
||||
alias(libs.plugins.anvil) |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.x.features.rageshake" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(project(":libraries:core")) |
||||
anvil(project(":anvilcodegen")) |
||||
implementation(project(":libraries:di")) |
||||
implementation(project(":anvilannotations")) |
||||
implementation(project(":libraries:designsystem")) |
||||
implementation(project(":libraries:elementresources")) |
||||
implementation(libs.mavericks.compose) |
||||
implementation(libs.squareup.seismic) |
||||
implementation(libs.androidx.datastore.preferences) |
||||
implementation(libs.coil) |
||||
implementation(libs.coil.compose) |
||||
ksp(libs.showkase.processor) |
||||
testImplementation(libs.test.junit) |
||||
androidTestImplementation(libs.test.junitext) |
||||
} |
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here. |
||||
# You can control the set of applied configuration files using the |
||||
# proguardFiles setting in build.gradle.kts. |
||||
# |
||||
# For more details, see |
||||
# http://developer.android.com/guide/developing/tools/proguard.html |
||||
|
||||
# If your project uses WebView with JS, uncomment the following |
||||
# and specify the fully qualified class name to the JavaScript interface |
||||
# class: |
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { |
||||
# public *; |
||||
#} |
||||
|
||||
# Uncomment this to preserve the line number information for |
||||
# debugging stack traces. |
||||
#-keepattributes SourceFile,LineNumberTable |
||||
|
||||
# If you keep the line number information, uncomment this to |
||||
# hide the original source file name. |
||||
#-renamesourcefileattribute SourceFile |
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest> |
||||
|
||||
</manifest> |
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class) |
||||
|
||||
package io.element.android.x.features.rageshake.bugreport |
||||
|
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.imePadding |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.systemBarsPadding |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.text.KeyboardOptions |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.material3.Button |
||||
import androidx.compose.material3.Checkbox |
||||
import androidx.compose.material3.CircularProgressIndicator |
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.OutlinedTextField |
||||
import androidx.compose.material3.Surface |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.platform.LocalContext |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.input.ImeAction |
||||
import androidx.compose.ui.text.input.KeyboardType |
||||
import androidx.compose.ui.text.style.TextAlign |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import coil.compose.AsyncImage |
||||
import coil.request.ImageRequest |
||||
import com.airbnb.mvrx.Fail |
||||
import com.airbnb.mvrx.Loading |
||||
import com.airbnb.mvrx.Success |
||||
import com.airbnb.mvrx.Uninitialized |
||||
import com.airbnb.mvrx.compose.collectAsState |
||||
import com.airbnb.mvrx.compose.mavericksViewModel |
||||
import io.element.android.x.core.compose.LogCompositions |
||||
import io.element.android.x.designsystem.ElementXTheme |
||||
import io.element.android.x.designsystem.components.LabelledCheckbox |
||||
import io.element.android.x.designsystem.components.dialogs.ErrorDialog |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun BugReportScreen( |
||||
viewModel: BugReportViewModel = mavericksViewModel(), |
||||
onDone: () -> Unit = { }, |
||||
) { |
||||
val state: BugReportViewState by viewModel.collectAsState() |
||||
val formState: BugReportFormState by viewModel.formState |
||||
LogCompositions(tag = "Rageshake", msg = "Root") |
||||
if (state.sending is Success) { |
||||
onDone() |
||||
} |
||||
BugReportContent( |
||||
state = state, |
||||
formState = formState, |
||||
onDescriptionChanged = viewModel::onSetDescription, |
||||
onSetSendLog = viewModel::onSetSendLog, |
||||
onSetSendCrashLog = viewModel::onSetSendCrashLog, |
||||
onSetCanContact = viewModel::onSetCanContact, |
||||
onSetSendScreenshot = viewModel::onSetSendScreenshot, |
||||
onSubmit = viewModel::onSubmit, |
||||
onFailureDialogClosed = viewModel::onFailureDialogClosed, |
||||
onDone = onDone, |
||||
) |
||||
} |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun BugReportContent( |
||||
state: BugReportViewState, |
||||
formState: BugReportFormState, |
||||
modifier: Modifier = Modifier, |
||||
onDescriptionChanged: (String) -> Unit = {}, |
||||
onSetSendLog: (Boolean) -> Unit = {}, |
||||
onSetSendCrashLog: (Boolean) -> Unit = {}, |
||||
onSetCanContact: (Boolean) -> Unit = {}, |
||||
onSetSendScreenshot: (Boolean) -> Unit = {}, |
||||
onSubmit: () -> Unit = {}, |
||||
onFailureDialogClosed: () -> Unit = { }, |
||||
onDone: () -> Unit = { }, |
||||
) { |
||||
Surface( |
||||
modifier = modifier, |
||||
color = MaterialTheme.colorScheme.background, |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.fillMaxSize() |
||||
.systemBarsPadding() |
||||
.imePadding() |
||||
) { |
||||
val scrollState = rememberScrollState() |
||||
Column( |
||||
modifier = Modifier |
||||
.verticalScroll( |
||||
state = scrollState, |
||||
) |
||||
.padding(horizontal = 16.dp), |
||||
) { |
||||
val isError = state.sending is Fail |
||||
val isFormEnabled = state.sending !is Loading |
||||
// Title |
||||
Text( |
||||
text = stringResource(id = ElementR.string.send_bug_report), |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(horizontal = 16.dp, vertical = 16.dp), |
||||
textAlign = TextAlign.Center, |
||||
fontWeight = FontWeight.Bold, |
||||
fontSize = 24.sp, |
||||
) |
||||
// Form |
||||
Text( |
||||
text = stringResource(id = ElementR.string.send_bug_report_description), |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(horizontal = 16.dp, vertical = 16.dp), |
||||
fontSize = 16.sp, |
||||
) |
||||
Column( |
||||
// modifier = Modifier.weight(1f), |
||||
) { |
||||
OutlinedTextField( |
||||
value = formState.description, |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(top = 16.dp), |
||||
enabled = isFormEnabled, |
||||
label = { |
||||
Text(text = stringResource(id = ElementR.string.send_bug_report_placeholder)) |
||||
}, |
||||
supportingText = { |
||||
Text(text = stringResource(id = ElementR.string.send_bug_report_description_in_english)) |
||||
}, |
||||
onValueChange = onDescriptionChanged, |
||||
keyboardOptions = KeyboardOptions( |
||||
keyboardType = KeyboardType.Text, |
||||
imeAction = ImeAction.Next |
||||
), |
||||
// TODO Error text too short |
||||
) |
||||
} |
||||
LabelledCheckbox( |
||||
checked = state.sendLogs, |
||||
onCheckedChange = onSetSendLog, |
||||
enabled = isFormEnabled, |
||||
text = stringResource(id = ElementR.string.send_bug_report_include_logs) |
||||
) |
||||
if (state.hasCrashLogs) { |
||||
LabelledCheckbox( |
||||
checked = state.sendCrashLogs, |
||||
onCheckedChange = onSetSendCrashLog, |
||||
enabled = isFormEnabled, |
||||
text = stringResource(id = ElementR.string.send_bug_report_include_crash_logs) |
||||
) |
||||
} |
||||
LabelledCheckbox( |
||||
checked = state.canContact, |
||||
onCheckedChange = onSetCanContact, |
||||
enabled = isFormEnabled, |
||||
text = stringResource(id = ElementR.string.you_may_contact_me) |
||||
) |
||||
if (state.screenshotUri != null) { |
||||
LabelledCheckbox( |
||||
checked = state.sendScreenshot, |
||||
onCheckedChange = onSetSendScreenshot, |
||||
enabled = isFormEnabled, |
||||
text = stringResource(id = ElementR.string.send_bug_report_include_screenshot) |
||||
) |
||||
if (state.sendScreenshot) { |
||||
Box( |
||||
modifier = Modifier.fillMaxWidth(), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
val context = LocalContext.current |
||||
val model = ImageRequest.Builder(context) |
||||
.data(state.screenshotUri) |
||||
.build() |
||||
AsyncImage( |
||||
modifier = Modifier.fillMaxWidth(fraction = 0.5f), |
||||
model = model, |
||||
contentDescription = null |
||||
) |
||||
} |
||||
} |
||||
} |
||||
// Submit |
||||
Button( |
||||
onClick = onSubmit, |
||||
enabled = state.submitEnabled, |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(vertical = 32.dp) |
||||
) { |
||||
Text(text = stringResource(id = ElementR.string.action_send)) |
||||
} |
||||
} |
||||
when (state.sending) { |
||||
Uninitialized -> Unit |
||||
is Loading -> { |
||||
CircularProgressIndicator( |
||||
progress = state.sendingProgress, |
||||
modifier = Modifier.align(Alignment.Center) |
||||
) |
||||
} |
||||
is Fail -> ErrorDialog( |
||||
content = state.sending.error.toString(), |
||||
onDismiss = onFailureDialogClosed, |
||||
) |
||||
is Success -> onDone() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview |
||||
fun BugReportContentPreview() { |
||||
ElementXTheme(darkTheme = false) { |
||||
BugReportContent( |
||||
state = BugReportViewState(), |
||||
formState = BugReportFormState.Default |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
|
||||
package io.element.android.x.features.rageshake.bugreport |
||||
|
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.snapshotFlow |
||||
import androidx.core.net.toUri |
||||
import com.airbnb.mvrx.Fail |
||||
import com.airbnb.mvrx.Loading |
||||
import com.airbnb.mvrx.MavericksViewModel |
||||
import com.airbnb.mvrx.MavericksViewModelFactory |
||||
import com.airbnb.mvrx.Success |
||||
import com.airbnb.mvrx.Uninitialized |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.x.anvilannotations.ContributesViewModel |
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory |
||||
import io.element.android.x.di.AppScope |
||||
import io.element.android.x.features.rageshake.crash.CrashDataStore |
||||
import io.element.android.x.features.rageshake.logs.VectorFileLogger |
||||
import io.element.android.x.features.rageshake.reporter.BugReporter |
||||
import io.element.android.x.features.rageshake.reporter.ReportType |
||||
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.flow.launchIn |
||||
import kotlinx.coroutines.flow.onEach |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@ContributesViewModel(AppScope::class) |
||||
class BugReportViewModel @AssistedInject constructor( |
||||
@Assisted initialState: BugReportViewState, |
||||
private val bugReporter: BugReporter, |
||||
private val crashDataStore: CrashDataStore, |
||||
private val screenshotHolder: ScreenshotHolder, |
||||
private val appCoroutineScope: CoroutineScope |
||||
) : |
||||
MavericksViewModel<BugReportViewState>(initialState) { |
||||
|
||||
companion object : |
||||
MavericksViewModelFactory<BugReportViewModel, BugReportViewState> by daggerMavericksViewModelFactory() |
||||
|
||||
var formState = mutableStateOf(BugReportFormState.Default) |
||||
private set |
||||
|
||||
init { |
||||
snapshotFlow { formState.value } |
||||
.onEach { |
||||
setState { copy(formState = it) } |
||||
}.launchIn(viewModelScope) |
||||
observerCrashDataStore() |
||||
setState { |
||||
copy( |
||||
screenshotUri = screenshotHolder.getFile()?.toUri()?.toString() |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun observerCrashDataStore() { |
||||
viewModelScope.launch { |
||||
crashDataStore.crashInfo().collect { |
||||
setState { |
||||
copy( |
||||
hasCrashLogs = it.isNotEmpty() |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private val listener: BugReporter.IMXBugReportListener = object : BugReporter.IMXBugReportListener { |
||||
override fun onUploadCancelled() { |
||||
setState { |
||||
copy( |
||||
sendingProgress = 0F, |
||||
sending = Uninitialized |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun onUploadFailed(reason: String?) { |
||||
setState { |
||||
copy( |
||||
sendingProgress = 0F, |
||||
sending = Fail(Exception(reason)) |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun onProgress(progress: Int) { |
||||
setState { |
||||
copy( |
||||
sendingProgress = progress.toFloat() / 100, |
||||
sending = Loading() |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun onUploadSucceed(reportUrl: String?) { |
||||
setState { |
||||
copy( |
||||
sendingProgress = 1F, |
||||
sending = Success(Unit) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onCleared() { |
||||
// Use appCoroutineScope because we don't want this coroutine to be cancelled |
||||
appCoroutineScope.launch(Dispatchers.IO) { |
||||
screenshotHolder.reset() |
||||
crashDataStore.reset() |
||||
VectorFileLogger.getFromTimber().reset() |
||||
} |
||||
super.onCleared() |
||||
} |
||||
|
||||
fun onSubmit() { |
||||
setState { |
||||
copy( |
||||
sendingProgress = 0F, |
||||
sending = Loading() |
||||
) |
||||
} |
||||
withState { state -> |
||||
bugReporter.sendBugReport( |
||||
coroutineScope = viewModelScope, |
||||
reportType = ReportType.BUG_REPORT, |
||||
withDevicesLogs = state.sendLogs, |
||||
withCrashLogs = state.hasCrashLogs && state.sendCrashLogs, |
||||
withKeyRequestHistory = false, |
||||
withScreenshot = state.sendScreenshot, |
||||
theBugDescription = state.formState.description, |
||||
serverVersion = "", |
||||
canContact = state.canContact, |
||||
customFields = emptyMap(), |
||||
listener = listener |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun onFailureDialogClosed() { |
||||
setState { |
||||
copy( |
||||
sendingProgress = 0F, |
||||
sending = Uninitialized |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun onSetDescription(str: String) { |
||||
formState.value = formState.value.copy(description = str) |
||||
setState { copy(sending = Uninitialized) } |
||||
} |
||||
|
||||
fun onSetSendLog(value: Boolean) = setState { copy(sendLogs = value) } |
||||
fun onSetSendCrashLog(value: Boolean) = setState { copy(sendCrashLogs = value) } |
||||
fun onSetCanContact(value: Boolean) = setState { copy(canContact = value) } |
||||
fun onSetSendScreenshot(value: Boolean) = setState { copy(sendScreenshot = value) } |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
package io.element.android.x.features.rageshake.bugreport |
||||
|
||||
import com.airbnb.mvrx.Async |
||||
import com.airbnb.mvrx.Loading |
||||
import com.airbnb.mvrx.MavericksState |
||||
import com.airbnb.mvrx.Uninitialized |
||||
|
||||
data class BugReportViewState( |
||||
val formState: BugReportFormState = BugReportFormState.Default, |
||||
val sendLogs: Boolean = true, |
||||
val hasCrashLogs: Boolean = false, |
||||
val sendCrashLogs: Boolean = true, |
||||
val canContact: Boolean = false, |
||||
val sendScreenshot: Boolean = false, |
||||
val screenshotUri: String? = null, |
||||
val sendingProgress: Float = 0F, |
||||
val sending: Async<Unit> = Uninitialized, |
||||
) : MavericksState { |
||||
val submitEnabled = |
||||
formState.description.length > 10 && sending !is Loading |
||||
} |
||||
|
||||
data class BugReportFormState( |
||||
val description: String, |
||||
) { |
||||
companion object { |
||||
val Default = BugReportFormState("") |
||||
} |
||||
} |
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
package io.element.android.x.features.rageshake.crash |
||||
|
||||
import android.content.Context |
||||
import androidx.datastore.core.DataStore |
||||
import androidx.datastore.preferences.core.Preferences |
||||
import androidx.datastore.preferences.core.booleanPreferencesKey |
||||
import androidx.datastore.preferences.core.edit |
||||
import androidx.datastore.preferences.core.stringPreferencesKey |
||||
import androidx.datastore.preferences.preferencesDataStore |
||||
import io.element.android.x.core.bool.orFalse |
||||
import io.element.android.x.di.ApplicationContext |
||||
import kotlinx.coroutines.flow.Flow |
||||
import kotlinx.coroutines.flow.map |
||||
import kotlinx.coroutines.runBlocking |
||||
import javax.inject.Inject |
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash") |
||||
|
||||
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") |
||||
private val crashDataKey = stringPreferencesKey("crashData") |
||||
|
||||
class CrashDataStore @Inject constructor( |
||||
@ApplicationContext context: Context |
||||
) { |
||||
private val store = context.dataStore |
||||
|
||||
fun setCrashData(crashData: String) { |
||||
// Must block |
||||
runBlocking { |
||||
store.edit { prefs -> |
||||
prefs[appHasCrashedKey] = true |
||||
prefs[crashDataKey] = crashData |
||||
} |
||||
} |
||||
} |
||||
|
||||
suspend fun resetAppHasCrashed() { |
||||
store.edit { prefs -> |
||||
prefs[appHasCrashedKey] = false |
||||
} |
||||
} |
||||
|
||||
fun appHasCrashed(): Flow<Boolean> { |
||||
return store.data.map { prefs -> |
||||
prefs[appHasCrashedKey].orFalse() |
||||
} |
||||
} |
||||
|
||||
fun crashInfo(): Flow<String> { |
||||
return store.data.map { prefs -> |
||||
prefs[crashDataKey].orEmpty() |
||||
} |
||||
} |
||||
|
||||
suspend fun reset() { |
||||
store.edit { it.clear() } |
||||
} |
||||
} |
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
package io.element.android.x.features.rageshake.crash |
||||
|
||||
import android.content.Context |
||||
import android.os.Build |
||||
import io.element.android.x.core.data.tryOrNull |
||||
import timber.log.Timber |
||||
import java.io.PrintWriter |
||||
import java.io.StringWriter |
||||
|
||||
class VectorUncaughtExceptionHandler( |
||||
context: Context |
||||
) : Thread.UncaughtExceptionHandler { |
||||
private val crashDataStore = CrashDataStore(context) |
||||
private var previousHandler: Thread.UncaughtExceptionHandler? = null |
||||
|
||||
/** |
||||
* Activate this handler. |
||||
*/ |
||||
fun activate() { |
||||
previousHandler = Thread.getDefaultUncaughtExceptionHandler() |
||||
Thread.setDefaultUncaughtExceptionHandler(this) |
||||
} |
||||
|
||||
/** |
||||
* An uncaught exception has been triggered. |
||||
* |
||||
* @param thread the thread |
||||
* @param throwable the throwable |
||||
*/ |
||||
@Suppress("PrintStackTrace") |
||||
override fun uncaughtException(thread: Thread, throwable: Throwable) { |
||||
Timber.v("Uncaught exception: $throwable") |
||||
val bugDescription = buildString { |
||||
val appName = "ElementX" |
||||
// append(appName + " Build : " + versionCodeProvider.getVersionCode() + "\n") |
||||
append("$appName Version : 1.0") // ${versionProvider.getVersion(longFormat = true)}\n") |
||||
// append("SDK Version : ${Matrix.getSdkVersion()}\n") |
||||
append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n") |
||||
append("Memory statuses \n") |
||||
var freeSize = 0L |
||||
var totalSize = 0L |
||||
var usedSize = -1L |
||||
tryOrNull { |
||||
val info = Runtime.getRuntime() |
||||
freeSize = info.freeMemory() |
||||
totalSize = info.totalMemory() |
||||
usedSize = totalSize - freeSize |
||||
} |
||||
append("usedSize " + usedSize / 1048576L + " MB\n") |
||||
append("freeSize " + freeSize / 1048576L + " MB\n") |
||||
append("totalSize " + totalSize / 1048576L + " MB\n") |
||||
append("Thread: ") |
||||
append(thread.name) |
||||
append(", Exception: ") |
||||
val sw = StringWriter() |
||||
val pw = PrintWriter(sw, true) |
||||
throwable.printStackTrace(pw) |
||||
append(sw.buffer.toString()) |
||||
} |
||||
Timber.e("FATAL EXCEPTION $bugDescription") |
||||
crashDataStore.setCrashData(bugDescription) |
||||
// Show the classical system popup |
||||
previousHandler?.uncaughtException(thread, throwable) |
||||
} |
||||
} |
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
package io.element.android.x.features.rageshake.crash.ui |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import com.airbnb.mvrx.compose.collectAsState |
||||
import com.airbnb.mvrx.compose.mavericksViewModel |
||||
import io.element.android.x.core.compose.LogCompositions |
||||
import io.element.android.x.designsystem.ElementXTheme |
||||
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun CrashDetectionScreen( |
||||
viewModel: CrashDetectionViewModel = mavericksViewModel(), |
||||
onOpenBugReport: () -> Unit = { }, |
||||
) { |
||||
val state: CrashDetectionViewState by viewModel.collectAsState() |
||||
LogCompositions(tag = "Crash", msg = "CrashDetectionScreen") |
||||
|
||||
if (state.crashDetected) { |
||||
CrashDetectionContent( |
||||
state, |
||||
onYesClicked = { |
||||
viewModel.onYes() |
||||
onOpenBugReport() |
||||
}, |
||||
onNoClicked = viewModel::onPopupDismissed, |
||||
onDismiss = viewModel::onPopupDismissed, |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun CrashDetectionContent( |
||||
state: CrashDetectionViewState, |
||||
onNoClicked: () -> Unit = { }, |
||||
onYesClicked: () -> Unit = { }, |
||||
onDismiss: () -> Unit = { }, |
||||
) { |
||||
ConfirmationDialog( |
||||
title = stringResource(id = ElementR.string.send_bug_report), |
||||
content = stringResource(id = ElementR.string.send_bug_report_app_crashed), |
||||
submitText = stringResource(id = ElementR.string.yes), |
||||
cancelText = stringResource(id = ElementR.string.no), |
||||
onCancelClicked = onNoClicked, |
||||
onSubmitClicked = onYesClicked, |
||||
onDismiss = onDismiss, |
||||
) |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
fun CrashDetectionContentPreview() { |
||||
ElementXTheme { |
||||
CrashDetectionContent( |
||||
state = CrashDetectionViewState() |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
package io.element.android.x.features.rageshake.crash.ui |
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel |
||||
import com.airbnb.mvrx.MavericksViewModelFactory |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.x.anvilannotations.ContributesViewModel |
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory |
||||
import io.element.android.x.di.AppScope |
||||
import io.element.android.x.features.rageshake.crash.CrashDataStore |
||||
import kotlinx.coroutines.launch |
||||
|
||||
@ContributesViewModel(AppScope::class) |
||||
class CrashDetectionViewModel @AssistedInject constructor( |
||||
@Assisted initialState: CrashDetectionViewState, |
||||
private val crashDataStore: CrashDataStore, |
||||
) : MavericksViewModel<CrashDetectionViewState>(initialState) { |
||||
|
||||
companion object : |
||||
MavericksViewModelFactory<CrashDetectionViewModel, CrashDetectionViewState> by daggerMavericksViewModelFactory() |
||||
|
||||
init { |
||||
observeDataStore() |
||||
} |
||||
|
||||
private fun observeDataStore() { |
||||
viewModelScope.launch { |
||||
crashDataStore.appHasCrashed().collect { appHasCrashed -> |
||||
setState { |
||||
copy( |
||||
crashDetected = appHasCrashed |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun onYes() { |
||||
viewModelScope.launch { |
||||
crashDataStore.resetAppHasCrashed() |
||||
} |
||||
} |
||||
|
||||
fun onPopupDismissed() { |
||||
viewModelScope.launch { |
||||
crashDataStore.reset() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
package io.element.android.x.features.rageshake.crash.ui |
||||
|
||||
import com.airbnb.mvrx.MavericksState |
||||
|
||||
data class CrashDetectionViewState( |
||||
val crashDetected: Boolean = false, |
||||
) : MavericksState |
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
package io.element.android.x.features.rageshake.detection |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.platform.LocalContext |
||||
import androidx.compose.ui.platform.LocalView |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.lifecycle.Lifecycle |
||||
import com.airbnb.mvrx.compose.collectAsState |
||||
import com.airbnb.mvrx.compose.mavericksViewModel |
||||
import io.element.android.x.core.compose.LogCompositions |
||||
import io.element.android.x.core.compose.OnLifecycleEvent |
||||
import io.element.android.x.core.hardware.vibrate |
||||
import io.element.android.x.core.screenshot.ImageResult |
||||
import io.element.android.x.core.screenshot.screenshot |
||||
import io.element.android.x.designsystem.ElementXTheme |
||||
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun RageshakeDetectionScreen( |
||||
viewModel: RageshakeDetectionViewModel = mavericksViewModel(), |
||||
onOpenBugReport: () -> Unit = { }, |
||||
) { |
||||
val state: RageshakeDetectionViewState by viewModel.collectAsState() |
||||
LogCompositions(tag = "Rageshake", msg = "RageshakeDetectionScreen") |
||||
val context = LocalContext.current |
||||
OnLifecycleEvent { _, event -> |
||||
when (event) { |
||||
Lifecycle.Event.ON_RESUME -> viewModel.start() |
||||
Lifecycle.Event.ON_PAUSE -> viewModel.stop() |
||||
else -> Unit |
||||
} |
||||
} |
||||
|
||||
when { |
||||
state.takeScreenshot -> TakeScreenshot( |
||||
onScreenshotTaken = viewModel::onScreenshotTaken |
||||
) |
||||
state.showDialog -> { |
||||
LaunchedEffect(key1 = "RS_diag") { |
||||
context.vibrate() |
||||
} |
||||
RageshakeDialogContent( |
||||
state, |
||||
onNoClicked = viewModel::onNo, |
||||
onDisableClicked = { |
||||
viewModel.onEnableClicked(false) |
||||
}, |
||||
onYesClicked = { |
||||
onOpenBugReport() |
||||
viewModel.onYes() |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun TakeScreenshot( |
||||
onScreenshotTaken: (ImageResult) -> Unit = {} |
||||
) { |
||||
val view = LocalView.current |
||||
view.screenshot { |
||||
onScreenshotTaken(it) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun RageshakeDialogContent( |
||||
state: RageshakeDetectionViewState, |
||||
onNoClicked: () -> Unit = { }, |
||||
onDisableClicked: () -> Unit = { }, |
||||
onYesClicked: () -> Unit = { }, |
||||
) { |
||||
ConfirmationDialog( |
||||
title = stringResource(id = ElementR.string.send_bug_report), |
||||
content = stringResource(id = ElementR.string.send_bug_report_alert_message), |
||||
thirdButtonText = stringResource(id = ElementR.string.action_disable), |
||||
submitText = stringResource(id = ElementR.string.yes), |
||||
cancelText = stringResource(id = ElementR.string.no), |
||||
onThirdButtonClicked = onDisableClicked, |
||||
onSubmitClicked = onYesClicked, |
||||
onDismiss = onNoClicked, |
||||
) |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
fun RageshakeDialogContentPreview() { |
||||
ElementXTheme { |
||||
RageshakeDialogContent( |
||||
state = RageshakeDetectionViewState() |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
package io.element.android.x.features.rageshake.detection |
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel |
||||
import com.airbnb.mvrx.MavericksViewModelFactory |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.x.anvilannotations.ContributesViewModel |
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory |
||||
import io.element.android.x.core.screenshot.ImageResult |
||||
import io.element.android.x.di.AppScope |
||||
import io.element.android.x.features.rageshake.rageshake.RageShake |
||||
import io.element.android.x.features.rageshake.rageshake.RageshakeDataStore |
||||
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.flow.distinctUntilChanged |
||||
import kotlinx.coroutines.flow.map |
||||
import kotlinx.coroutines.launch |
||||
import timber.log.Timber |
||||
|
||||
@ContributesViewModel(AppScope::class) |
||||
class RageshakeDetectionViewModel @AssistedInject constructor( |
||||
@Assisted initialState: RageshakeDetectionViewState, |
||||
private val rageshakeDataStore: RageshakeDataStore, |
||||
private val screenshotHolder: ScreenshotHolder, |
||||
private val rageShake: RageShake, |
||||
) : MavericksViewModel<RageshakeDetectionViewState>(initialState) { |
||||
|
||||
companion object : |
||||
MavericksViewModelFactory<RageshakeDetectionViewModel, RageshakeDetectionViewState> by daggerMavericksViewModelFactory() |
||||
|
||||
init { |
||||
setState { |
||||
copy( |
||||
isSupported = rageShake.isAvailable() |
||||
) |
||||
} |
||||
observeDataStore() |
||||
observeState() |
||||
} |
||||
|
||||
private fun observeDataStore() { |
||||
viewModelScope.launch { |
||||
rageshakeDataStore.isEnabled().collect { isEnabled -> |
||||
setState { |
||||
copy( |
||||
isEnabled = isEnabled |
||||
) |
||||
} |
||||
} |
||||
} |
||||
viewModelScope.launch { |
||||
rageshakeDataStore.sensitivity().collect { sensitivity -> |
||||
setState { |
||||
copy( |
||||
sensitivity = sensitivity |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun observeState() { |
||||
viewModelScope.launch { |
||||
stateFlow |
||||
.map { |
||||
it.isSupported && |
||||
it.isEnabled && |
||||
it.isStarted && |
||||
!it.takeScreenshot && |
||||
!it.showDialog |
||||
} |
||||
.distinctUntilChanged() |
||||
.collect(::handleRageShake) |
||||
} |
||||
viewModelScope.launch { |
||||
stateFlow |
||||
.map { |
||||
it.sensitivity |
||||
} |
||||
.distinctUntilChanged() |
||||
.collect { |
||||
rageShake.setSensitivity(it) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun handleRageShake(shouldStart: Boolean) { |
||||
if (shouldStart) { |
||||
withState { |
||||
rageShake.start(it.sensitivity) |
||||
} |
||||
rageShake.interceptor = { |
||||
setState { |
||||
copy( |
||||
takeScreenshot = true |
||||
) |
||||
} |
||||
} |
||||
} else { |
||||
rageShake.stop() |
||||
rageShake.interceptor = null |
||||
} |
||||
} |
||||
|
||||
fun onScreenshotTaken(imageResult: ImageResult) { |
||||
viewModelScope.launch(Dispatchers.IO) { |
||||
screenshotHolder.reset() |
||||
when (imageResult) { |
||||
is ImageResult.Error -> { |
||||
Timber.e(imageResult.exception, "Unable to write screenshot") |
||||
} |
||||
is ImageResult.Success -> { |
||||
screenshotHolder.writeBitmap(imageResult.data) |
||||
} |
||||
} |
||||
setState { |
||||
copy( |
||||
takeScreenshot = false, |
||||
showDialog = true, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun start() { |
||||
setState { |
||||
copy(isStarted = true) |
||||
} |
||||
} |
||||
|
||||
private fun onPopupDismissed() { |
||||
setState { |
||||
copy( |
||||
showDialog = false |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun onNo() { |
||||
onPopupDismissed() |
||||
} |
||||
|
||||
fun onYes() { |
||||
onPopupDismissed() |
||||
} |
||||
|
||||
fun onEnableClicked(enabled: Boolean) { |
||||
viewModelScope.launch { |
||||
rageshakeDataStore.setIsEnabled(enabled) |
||||
} |
||||
if (!enabled) { |
||||
onPopupDismissed() |
||||
} |
||||
} |
||||
|
||||
fun onSensitivityChange(sensitivity: Float) { |
||||
viewModelScope.launch { |
||||
rageshakeDataStore.setSensitivity(sensitivity) |
||||
} |
||||
rageShake.setSensitivity(sensitivity) |
||||
} |
||||
|
||||
fun stop() { |
||||
setState { |
||||
copy(isStarted = false) |
||||
} |
||||
} |
||||
|
||||
override fun onCleared() { |
||||
super.onCleared() |
||||
stop() |
||||
handleRageShake(false) |
||||
} |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
package io.element.android.x.features.rageshake.detection |
||||
|
||||
import com.airbnb.mvrx.MavericksState |
||||
|
||||
data class RageshakeDetectionViewState( |
||||
val takeScreenshot: Boolean = false, |
||||
val showDialog: Boolean = false, |
||||
val isEnabled: Boolean = true, |
||||
val isStarted: Boolean = false, |
||||
val isSupported: Boolean = false, |
||||
val sensitivity: Float = 0.5f, |
||||
) : MavericksState |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
package io.element.android.x.features.rageshake.logs |
||||
|
||||
import java.io.PrintWriter |
||||
import java.io.StringWriter |
||||
import java.text.SimpleDateFormat |
||||
import java.util.Date |
||||
import java.util.Locale |
||||
import java.util.TimeZone |
||||
import java.util.logging.Formatter |
||||
import java.util.logging.LogRecord |
||||
|
||||
internal class LogFormatter : Formatter() { |
||||
|
||||
override fun format(r: LogRecord): String { |
||||
if (!mIsTimeZoneSet) { |
||||
DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC") |
||||
mIsTimeZoneSet = true |
||||
} |
||||
|
||||
val thrown = r.thrown |
||||
if (thrown != null) { |
||||
val sw = StringWriter() |
||||
val pw = PrintWriter(sw) |
||||
sw.write(r.message) |
||||
sw.write(LINE_SEPARATOR) |
||||
thrown.printStackTrace(pw) |
||||
pw.flush() |
||||
return sw.toString() |
||||
} else { |
||||
val b = StringBuilder() |
||||
val date = DATE_FORMAT.format(Date(r.millis)) |
||||
b.append(date) |
||||
b.append("Z ") |
||||
b.append(r.message) |
||||
b.append(LINE_SEPARATOR) |
||||
return b.toString() |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n" |
||||
|
||||
// private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US) |
||||
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US) |
||||
|
||||
private var mIsTimeZoneSet = false |
||||
} |
||||
} |
@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
|
||||
package io.element.android.x.features.rageshake.logs |
||||
|
||||
import android.content.Context |
||||
import android.util.Log |
||||
import io.element.android.x.core.data.tryOrNull |
||||
import kotlinx.coroutines.DelicateCoroutinesApi |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.GlobalScope |
||||
import kotlinx.coroutines.launch |
||||
import timber.log.Timber |
||||
import java.io.File |
||||
import java.io.PrintWriter |
||||
import java.io.StringWriter |
||||
import java.util.logging.FileHandler |
||||
import java.util.logging.Level |
||||
import java.util.logging.Logger |
||||
|
||||
/** |
||||
* Will be planted in Timber. |
||||
*/ |
||||
class VectorFileLogger( |
||||
context: Context, |
||||
// private val vectorPreferences: VectorPreferences |
||||
) : Timber.Tree() { |
||||
|
||||
companion object { |
||||
fun getFromTimber(): VectorFileLogger { |
||||
return Timber.forest().filterIsInstance<VectorFileLogger>().first() |
||||
} |
||||
|
||||
private const val SIZE_20MB = 20 * 1024 * 1024 |
||||
private const val SIZE_50MB = 50 * 1024 * 1024 |
||||
} |
||||
|
||||
/* |
||||
private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB |
||||
private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7 |
||||
*/ |
||||
private val maxLogSizeByte = SIZE_20MB |
||||
private val logRotationCount = 7 |
||||
|
||||
private val logger = Logger.getLogger(context.packageName).apply { |
||||
tryOrNull { |
||||
useParentHandlers = false |
||||
level = Level.ALL |
||||
} |
||||
} |
||||
|
||||
private val fileHandler: FileHandler? |
||||
private val cacheDirectory = File(context.cacheDir, "logs") |
||||
private var fileNamePrefix = "logs" |
||||
|
||||
private val prioPrefixes = mapOf( |
||||
Log.VERBOSE to "V/ ", |
||||
Log.DEBUG to "D/ ", |
||||
Log.INFO to "I/ ", |
||||
Log.WARN to "W/ ", |
||||
Log.ERROR to "E/ ", |
||||
Log.ASSERT to "WTF/ " |
||||
) |
||||
|
||||
init { |
||||
if (!cacheDirectory.exists()) { |
||||
cacheDirectory.mkdirs() |
||||
} |
||||
|
||||
for (i in 0..15) { |
||||
val file = File(cacheDirectory, "elementLogs.${i}.txt") |
||||
tryOrNull { file.delete() } |
||||
} |
||||
|
||||
fileHandler = tryOrNull("Failed to initialize FileLogger") { |
||||
FileHandler( |
||||
cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt", |
||||
maxLogSizeByte, |
||||
logRotationCount |
||||
) |
||||
.also { it.formatter = LogFormatter() } |
||||
.also { logger.addHandler(it) } |
||||
} |
||||
} |
||||
|
||||
fun reset() { |
||||
// Delete all files |
||||
getLogFiles().map { |
||||
tryOrNull { it.delete() } |
||||
} |
||||
} |
||||
|
||||
@OptIn(DelicateCoroutinesApi::class) |
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { |
||||
fileHandler ?: return |
||||
GlobalScope.launch(Dispatchers.IO) { |
||||
if (skipLog(priority)) return@launch |
||||
if (t != null) { |
||||
logToFile(t) |
||||
} |
||||
logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message) |
||||
} |
||||
} |
||||
|
||||
private fun skipLog(priority: Int): Boolean { |
||||
/* |
||||
return if (vectorPreferences.labAllowedExtendedLogging()) { |
||||
false |
||||
} else { |
||||
// Exclude verbose logs |
||||
priority < Log.DEBUG |
||||
} |
||||
*/ |
||||
// Exclude verbose logs |
||||
return priority < Log.DEBUG |
||||
} |
||||
|
||||
/** |
||||
* Adds our own log files to the provided list of files. |
||||
* |
||||
* @return The list of files with logs. |
||||
*/ |
||||
fun getLogFiles(): List<File> { |
||||
return tryOrNull("## getLogFiles() failed") { |
||||
fileHandler |
||||
?.flush() |
||||
?.let { 0 until logRotationCount } |
||||
?.mapNotNull { index -> |
||||
File(cacheDirectory, "$fileNamePrefix.${index}.txt") |
||||
.takeIf { it.exists() } |
||||
} |
||||
} |
||||
.orEmpty() |
||||
} |
||||
|
||||
/** |
||||
* Log an Throwable. |
||||
* |
||||
* @param throwable the throwable to log |
||||
*/ |
||||
private fun logToFile(throwable: Throwable?) { |
||||
throwable ?: return |
||||
|
||||
val errors = StringWriter() |
||||
throwable.printStackTrace(PrintWriter(errors)) |
||||
|
||||
logger.info(errors.toString()) |
||||
} |
||||
|
||||
private fun logToFile(level: String, tag: String, content: String) { |
||||
val b = StringBuilder() |
||||
b.append(Thread.currentThread().id) |
||||
b.append(" ") |
||||
b.append(level) |
||||
b.append("/") |
||||
b.append(tag) |
||||
b.append(": ") |
||||
b.append(content) |
||||
logger.info(b.toString()) |
||||
} |
||||
} |
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
package io.element.android.x.features.rageshake.preferences |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import com.airbnb.mvrx.compose.collectAsState |
||||
import com.airbnb.mvrx.compose.mavericksViewModel |
||||
import io.element.android.x.designsystem.components.preferences.PreferenceCategory |
||||
import io.element.android.x.designsystem.components.preferences.PreferenceSlide |
||||
import io.element.android.x.designsystem.components.preferences.PreferenceSwitch |
||||
import io.element.android.x.designsystem.components.preferences.PreferenceText |
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewModel |
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionViewState |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun RageshakePreferenceCategory() { |
||||
RageshakePreferenceContent() |
||||
} |
||||
|
||||
@Composable |
||||
fun RageshakePreferenceContent( |
||||
viewModel: RageshakeDetectionViewModel = mavericksViewModel() |
||||
) { |
||||
val state: RageshakeDetectionViewState by viewModel.collectAsState() |
||||
PreferenceCategory(title = stringResource(id = ElementR.string.settings_rageshake)) { |
||||
if (state.isSupported) { |
||||
PreferenceSwitch( |
||||
title = stringResource(id = ElementR.string.send_bug_report_rage_shake), |
||||
isChecked = state.isEnabled, |
||||
onCheckedChange = viewModel::onEnableClicked |
||||
) |
||||
if (state.isEnabled) { |
||||
PreferenceSlide( |
||||
title = stringResource(id = ElementR.string.settings_rageshake_detection_threshold), |
||||
// summary = stringResource(id = ElementR.string.settings_rageshake_detection_threshold_summary), |
||||
value = state.sensitivity, |
||||
steps = 3 /* 5 possible values - steps are in ]0, 1[ */, |
||||
onValueChange = viewModel::onSensitivityChange |
||||
) |
||||
} |
||||
} else { |
||||
PreferenceText(title = "Rageshaking is not supported by your device") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview |
||||
fun RageshakePreferenceCategoryPreview() { |
||||
RageshakePreferenceCategory() |
||||
} |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
package io.element.android.x.features.rageshake.rageshake |
||||
|
||||
import android.content.Context |
||||
import android.hardware.Sensor |
||||
import android.hardware.SensorManager |
||||
import androidx.core.content.getSystemService |
||||
import com.squareup.seismic.ShakeDetector |
||||
import io.element.android.x.di.AppScope |
||||
import io.element.android.x.di.ApplicationContext |
||||
import io.element.android.x.di.SingleIn |
||||
import javax.inject.Inject |
||||
|
||||
@SingleIn(AppScope::class) |
||||
class RageShake @Inject constructor( |
||||
@ApplicationContext context: Context, |
||||
) : ShakeDetector.Listener { |
||||
|
||||
private var sensorManager = context.getSystemService<SensorManager>() |
||||
private var shakeDetector: ShakeDetector? = null |
||||
|
||||
var interceptor: (() -> Unit)? = null |
||||
|
||||
/** |
||||
* Check if the feature is available on this device. |
||||
*/ |
||||
fun isAvailable(): Boolean { |
||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null |
||||
} |
||||
|
||||
fun start(sensitivity: Float) { |
||||
sensorManager?.let { |
||||
shakeDetector = ShakeDetector(this).apply { |
||||
start(it, SensorManager.SENSOR_DELAY_GAME) |
||||
} |
||||
setSensitivity(sensitivity) |
||||
} |
||||
} |
||||
|
||||
fun stop() { |
||||
shakeDetector?.stop() |
||||
} |
||||
|
||||
/** |
||||
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)] |
||||
*/ |
||||
fun setSensitivity(sensitivity: Float) { |
||||
shakeDetector?.setSensitivity( |
||||
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt() |
||||
) |
||||
} |
||||
|
||||
override fun hearShake() { |
||||
interceptor?.invoke() |
||||
} |
||||
} |
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
package io.element.android.x.features.rageshake.rageshake |
||||
|
||||
import android.content.Context |
||||
import androidx.datastore.core.DataStore |
||||
import androidx.datastore.preferences.core.Preferences |
||||
import androidx.datastore.preferences.core.booleanPreferencesKey |
||||
import androidx.datastore.preferences.core.edit |
||||
import androidx.datastore.preferences.core.floatPreferencesKey |
||||
import androidx.datastore.preferences.preferencesDataStore |
||||
import io.element.android.x.core.bool.orTrue |
||||
import io.element.android.x.di.ApplicationContext |
||||
import kotlinx.coroutines.flow.Flow |
||||
import kotlinx.coroutines.flow.map |
||||
import javax.inject.Inject |
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake") |
||||
|
||||
private val enabledKey = booleanPreferencesKey("enabled") |
||||
private val sensitivityKey = floatPreferencesKey("sensitivity") |
||||
|
||||
class RageshakeDataStore @Inject constructor( |
||||
@ApplicationContext context: Context |
||||
) { |
||||
private val store = context.dataStore |
||||
|
||||
fun isEnabled(): Flow<Boolean> { |
||||
return store.data.map { prefs -> |
||||
prefs[enabledKey].orTrue() |
||||
} |
||||
} |
||||
|
||||
suspend fun setIsEnabled(isEnabled: Boolean) { |
||||
store.edit { prefs -> |
||||
prefs[enabledKey] = isEnabled |
||||
} |
||||
} |
||||
|
||||
fun sensitivity(): Flow<Float> { |
||||
return store.data.map { prefs -> |
||||
prefs[sensitivityKey] ?: 0.5f |
||||
} |
||||
} |
||||
|
||||
suspend fun setSensitivity(sensitivity: Float) { |
||||
store.edit { prefs -> |
||||
prefs[sensitivityKey] = sensitivity |
||||
} |
||||
} |
||||
|
||||
suspend fun reset() { |
||||
store.edit { it.clear() } |
||||
} |
||||
} |
@ -0,0 +1,533 @@
@@ -0,0 +1,533 @@
|
||||
package io.element.android.x.features.rageshake.reporter |
||||
|
||||
import android.content.Context |
||||
import android.os.Build |
||||
import io.element.android.x.core.extensions.toOnOff |
||||
import io.element.android.x.core.file.compressFile |
||||
import io.element.android.x.core.mimetype.MimeTypes |
||||
import io.element.android.x.di.ApplicationContext |
||||
import io.element.android.x.features.rageshake.R |
||||
import io.element.android.x.features.rageshake.crash.CrashDataStore |
||||
import io.element.android.x.features.rageshake.logs.VectorFileLogger |
||||
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.flow.first |
||||
import kotlinx.coroutines.launch |
||||
import kotlinx.coroutines.withContext |
||||
import okhttp3.Call |
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull |
||||
import okhttp3.OkHttpClient |
||||
import okhttp3.Request |
||||
import okhttp3.RequestBody.Companion.asRequestBody |
||||
import okhttp3.Response |
||||
import org.json.JSONException |
||||
import org.json.JSONObject |
||||
import timber.log.Timber |
||||
import java.io.File |
||||
import java.io.IOException |
||||
import java.io.OutputStreamWriter |
||||
import java.net.HttpURLConnection |
||||
import java.util.Locale |
||||
import javax.inject.Inject |
||||
|
||||
/** |
||||
* BugReporter creates and sends the bug reports. |
||||
*/ |
||||
class BugReporter @Inject constructor( |
||||
@ApplicationContext private val context: Context, |
||||
private val screenshotHolder: ScreenshotHolder, |
||||
private val crashDataStore: CrashDataStore, |
||||
/* |
||||
private val activeSessionHolder: ActiveSessionHolder, |
||||
private val versionProvider: VersionProvider, |
||||
private val vectorPreferences: VectorPreferences, |
||||
private val vectorFileLogger: VectorFileLogger, |
||||
private val systemLocaleProvider: SystemLocaleProvider, |
||||
private val matrix: Matrix, |
||||
private val buildMeta: BuildMeta, |
||||
private val processInfo: ProcessInfo, |
||||
private val sdkIntProvider: BuildVersionSdkIntProvider, |
||||
private val vectorLocale: VectorLocaleProvider, |
||||
*/ |
||||
) { |
||||
var inMultiWindowMode = false |
||||
|
||||
companion object { |
||||
// filenames |
||||
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log" |
||||
private const val LOG_CAT_FILENAME = "logcat.log" |
||||
private const val KEY_REQUESTS_FILENAME = "keyRequests.log" |
||||
|
||||
private const val BUFFER_SIZE = 1024 * 1024 * 50 |
||||
} |
||||
|
||||
// the http client |
||||
private val mOkHttpClient = OkHttpClient() |
||||
|
||||
// the pending bug report call |
||||
private var mBugReportCall: Call? = null |
||||
|
||||
// boolean to cancel the bug report |
||||
private val mIsCancelled = false |
||||
|
||||
/* |
||||
val adapter = MatrixJsonParser.getMoshi() |
||||
.adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) |
||||
*/ |
||||
|
||||
private val LOGCAT_CMD_ERROR = arrayOf( |
||||
"logcat", // /< Run 'logcat' command |
||||
"-d", // /< Dump the log rather than continue outputting it |
||||
"-v", // formatting |
||||
"threadtime", // include timestamps |
||||
"AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging |
||||
"libcommunicator:V " + // /< All libcommunicator logging |
||||
"DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc) |
||||
"*:S" // /< Everything else silent, so don't pick it.. |
||||
) |
||||
|
||||
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") |
||||
|
||||
/** |
||||
* Bug report upload listener. |
||||
*/ |
||||
interface IMXBugReportListener { |
||||
/** |
||||
* The bug report has been cancelled. |
||||
*/ |
||||
fun onUploadCancelled() |
||||
|
||||
/** |
||||
* The bug report upload failed. |
||||
* |
||||
* @param reason the failure reason |
||||
*/ |
||||
fun onUploadFailed(reason: String?) |
||||
|
||||
/** |
||||
* The upload progress (in percent). |
||||
* |
||||
* @param progress the upload progress |
||||
*/ |
||||
fun onProgress(progress: Int) |
||||
|
||||
/** |
||||
* The bug report upload succeeded. |
||||
*/ |
||||
fun onUploadSucceed(reportUrl: String?) |
||||
} |
||||
|
||||
/** |
||||
* Send a bug report. |
||||
* |
||||
* @param reportType The report type (bug, suggestion, feedback) |
||||
* @param withDevicesLogs true to include the device log |
||||
* @param withCrashLogs true to include the crash logs |
||||
* @param withKeyRequestHistory true to include the crash logs |
||||
* @param withScreenshot true to include the screenshot |
||||
* @param theBugDescription the bug description |
||||
* @param serverVersion version of the server |
||||
* @param canContact true if the user opt in to be contacted directly |
||||
* @param customFields fields which will be sent with the report |
||||
* @param listener the listener |
||||
*/ |
||||
fun sendBugReport( |
||||
coroutineScope: CoroutineScope, |
||||
reportType: ReportType, |
||||
withDevicesLogs: Boolean, |
||||
withCrashLogs: Boolean, |
||||
withKeyRequestHistory: Boolean, |
||||
withScreenshot: Boolean, |
||||
theBugDescription: String, |
||||
serverVersion: String, |
||||
canContact: Boolean = false, |
||||
customFields: Map<String, String>? = null, |
||||
listener: IMXBugReportListener? |
||||
) { |
||||
// enumerate files to delete |
||||
val mBugReportFiles: MutableList<File> = ArrayList() |
||||
|
||||
coroutineScope.launch { |
||||
var serverError: String? = null |
||||
var reportURL: String? = null |
||||
withContext(Dispatchers.IO) { |
||||
var bugDescription = theBugDescription |
||||
val crashCallStack = crashDataStore.crashInfo().first() |
||||
|
||||
if (crashCallStack.isNotEmpty() && withCrashLogs) { |
||||
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" |
||||
bugDescription += crashCallStack |
||||
} |
||||
|
||||
val gzippedFiles = ArrayList<File>() |
||||
|
||||
val vectorFileLogger = VectorFileLogger.getFromTimber() |
||||
if (withDevicesLogs) { |
||||
val files = vectorFileLogger.getLogFiles() |
||||
files.mapNotNullTo(gzippedFiles) { f -> |
||||
if (!mIsCancelled) { |
||||
compressFile(f) |
||||
} else { |
||||
null |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { |
||||
val gzippedLogcat = saveLogCat(false) |
||||
|
||||
if (null != gzippedLogcat) { |
||||
if (gzippedFiles.size == 0) { |
||||
gzippedFiles.add(gzippedLogcat) |
||||
} else { |
||||
gzippedFiles.add(0, gzippedLogcat) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/* |
||||
activeSessionHolder.getSafeActiveSession() |
||||
?.takeIf { !mIsCancelled && withKeyRequestHistory } |
||||
?.cryptoService() |
||||
?.getGossipingEvents() |
||||
?.let { GossipingEventsSerializer().serialize(it) } |
||||
?.toByteArray() |
||||
?.let { rawByteArray -> |
||||
File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME) |
||||
.also { |
||||
it.outputStream() |
||||
.use { os -> os.write(rawByteArray) } |
||||
} |
||||
} |
||||
?.let { compressFile(it) } |
||||
?.let { gzippedFiles.add(it) } |
||||
*/ |
||||
|
||||
var deviceId = "undefined" |
||||
var userId = "undefined" |
||||
var olmVersion = "undefined" |
||||
|
||||
/* |
||||
activeSessionHolder.getSafeActiveSession()?.let { session -> |
||||
userId = session.myUserId |
||||
deviceId = session.sessionParams.deviceId ?: "undefined" |
||||
olmVersion = session.cryptoService().getCryptoVersion(context, true) |
||||
} |
||||
*/ |
||||
|
||||
if (!mIsCancelled) { |
||||
val text = when (reportType) { |
||||
ReportType.BUG_REPORT -> "[ElementX] $bugDescription" |
||||
ReportType.SUGGESTION -> "[ElementX] [Suggestion] $bugDescription" |
||||
ReportType.SPACE_BETA_FEEDBACK -> "[ElementX] [spaces-feedback] $bugDescription" |
||||
ReportType.THREADS_BETA_FEEDBACK -> "[ElementX] [threads-feedback] $bugDescription" |
||||
ReportType.AUTO_UISI_SENDER, |
||||
ReportType.AUTO_UISI -> bugDescription |
||||
} |
||||
|
||||
// build the multi part request |
||||
val builder = BugReporterMultipartBody.Builder() |
||||
.addFormDataPart("text", text) |
||||
.addFormDataPart("app", rageShakeAppNameForReport(reportType)) |
||||
// .addFormDataPart("user_agent", matrix.getUserAgent()) |
||||
.addFormDataPart("user_id", userId) |
||||
.addFormDataPart("can_contact", canContact.toString()) |
||||
.addFormDataPart("device_id", deviceId) |
||||
// .addFormDataPart("version", versionProvider.getVersion(longFormat = true)) |
||||
// .addFormDataPart("branch_name", buildMeta.gitBranchName) |
||||
// .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) |
||||
.addFormDataPart("olm_version", olmVersion) |
||||
.addFormDataPart("device", Build.MODEL.trim()) |
||||
// .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff()) |
||||
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff()) |
||||
//.addFormDataPart( |
||||
// "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " + |
||||
// Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME |
||||
//) |
||||
.addFormDataPart("locale", Locale.getDefault().toString()) |
||||
//.addFormDataPart("app_language", vectorLocale.applicationLocale.toString()) |
||||
//.addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString()) |
||||
//.addFormDataPart("theme", ThemeUtils.getApplicationTheme(context)) |
||||
.addFormDataPart("server_version", serverVersion) |
||||
.apply { |
||||
customFields?.forEach { (name, value) -> |
||||
addFormDataPart(name, value) |
||||
} |
||||
} |
||||
|
||||
// add the gzipped files |
||||
for (file in gzippedFiles) { |
||||
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())) |
||||
} |
||||
|
||||
mBugReportFiles.addAll(gzippedFiles) |
||||
|
||||
if (withScreenshot) { |
||||
screenshotHolder.getFile()?.let { screenshotFile -> |
||||
try { |
||||
builder.addFormDataPart( |
||||
"file", |
||||
screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) |
||||
) |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## sendBugReport() : fail to write screenshot") |
||||
} |
||||
} |
||||
} |
||||
|
||||
// add some github labels |
||||
// builder.addFormDataPart("label", buildMeta.versionName) |
||||
// builder.addFormDataPart("label", buildMeta.flavorDescription) |
||||
// builder.addFormDataPart("label", buildMeta.gitBranchName) |
||||
|
||||
// Special for ElementX |
||||
builder.addFormDataPart("label", "[ElementX]") |
||||
|
||||
// Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release". |
||||
// builder.addFormDataPart("label", BuildConfig.BUILD_TYPE) |
||||
|
||||
when (reportType) { |
||||
ReportType.BUG_REPORT -> { |
||||
/* nop */ |
||||
} |
||||
ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]") |
||||
ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback") |
||||
ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback") |
||||
ReportType.AUTO_UISI -> { |
||||
builder.addFormDataPart("label", "Z-UISI") |
||||
builder.addFormDataPart("label", "android") |
||||
builder.addFormDataPart("label", "uisi-recipient") |
||||
} |
||||
ReportType.AUTO_UISI_SENDER -> { |
||||
builder.addFormDataPart("label", "Z-UISI") |
||||
builder.addFormDataPart("label", "android") |
||||
builder.addFormDataPart("label", "uisi-sender") |
||||
} |
||||
} |
||||
|
||||
if (crashCallStack.isNotEmpty() && withCrashLogs) { |
||||
builder.addFormDataPart("label", "crash") |
||||
} |
||||
|
||||
val requestBody = builder.build() |
||||
|
||||
// add a progress listener |
||||
requestBody.setWriteListener { totalWritten, contentLength -> |
||||
val percentage = if (-1L != contentLength) { |
||||
if (totalWritten > contentLength) { |
||||
100 |
||||
} else { |
||||
(totalWritten * 100 / contentLength).toInt() |
||||
} |
||||
} else { |
||||
0 |
||||
} |
||||
|
||||
if (mIsCancelled && null != mBugReportCall) { |
||||
mBugReportCall!!.cancel() |
||||
} |
||||
|
||||
Timber.v("## onWrite() : $percentage%") |
||||
try { |
||||
listener?.onProgress(percentage) |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## onProgress() : failed") |
||||
} |
||||
} |
||||
|
||||
// build the request |
||||
val request = Request.Builder() |
||||
.url(context.getString(R.string.bug_report_url)) |
||||
.post(requestBody) |
||||
.build() |
||||
|
||||
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR |
||||
var response: Response? = null |
||||
var errorMessage: String? = null |
||||
|
||||
// trigger the request |
||||
try { |
||||
mBugReportCall = mOkHttpClient.newCall(request) |
||||
response = mBugReportCall!!.execute() |
||||
responseCode = response.code |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "response") |
||||
errorMessage = e.localizedMessage |
||||
} |
||||
|
||||
// if the upload failed, try to retrieve the reason |
||||
if (responseCode != HttpURLConnection.HTTP_OK) { |
||||
if (null != errorMessage) { |
||||
serverError = "Failed with error $errorMessage" |
||||
} else if (response?.body == null) { |
||||
serverError = "Failed with error $responseCode" |
||||
} else { |
||||
try { |
||||
val inputStream = response.body!!.byteStream() |
||||
|
||||
serverError = inputStream.use { |
||||
buildString { |
||||
var ch = it.read() |
||||
while (ch != -1) { |
||||
append(ch.toChar()) |
||||
ch = it.read() |
||||
} |
||||
} |
||||
} |
||||
|
||||
// check if the error message |
||||
serverError?.let { |
||||
try { |
||||
val responseJSON = JSONObject(it) |
||||
serverError = responseJSON.getString("error") |
||||
} catch (e: JSONException) { |
||||
Timber.e(e, "doInBackground ; Json conversion failed") |
||||
} |
||||
} |
||||
|
||||
// should never happen |
||||
if (null == serverError) { |
||||
serverError = "Failed with error $responseCode" |
||||
} |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## sendBugReport() : failed to parse error") |
||||
} |
||||
} |
||||
} else { |
||||
/* |
||||
reportURL = response?.body?.string()?.let { stringBody -> |
||||
adapter.fromJson(stringBody)?.get("report_url")?.toString() |
||||
} |
||||
*/ |
||||
} |
||||
} |
||||
} |
||||
|
||||
withContext(Dispatchers.Main) { |
||||
mBugReportCall = null |
||||
|
||||
// delete when the bug report has been successfully sent |
||||
for (file in mBugReportFiles) { |
||||
file.delete() |
||||
} |
||||
|
||||
if (null != listener) { |
||||
try { |
||||
if (mIsCancelled) { |
||||
listener.onUploadCancelled() |
||||
} else if (null == serverError) { |
||||
listener.onUploadSucceed(reportURL) |
||||
} else { |
||||
listener.onUploadFailed(serverError) |
||||
} |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## onPostExecute() : failed") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Send a bug report either with email or with Vector. |
||||
*/ |
||||
/* TODO Remove |
||||
fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) { |
||||
screenshot = takeScreenshot(activity) |
||||
logDbInfo() |
||||
logProcessInfo() |
||||
logOtherInfo() |
||||
activity.startActivity(BugReportActivity.intent(activity, reportType)) |
||||
} |
||||
*/ |
||||
|
||||
//private fun logOtherInfo() { |
||||
// Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState()) |
||||
//} |
||||
|
||||
//private fun logDbInfo() { |
||||
// val dbInfo = matrix.debugService().getDbUsageInfo() |
||||
// Timber.i(dbInfo) |
||||
//} |
||||
|
||||
//private fun logProcessInfo() { |
||||
// val pInfo = processInfo.getInfo() |
||||
// Timber.i(pInfo) |
||||
//} |
||||
|
||||
private fun rageShakeAppNameForReport(reportType: ReportType): String { |
||||
// As per https://github.com/matrix-org/rageshake |
||||
// app: Identifier for the application (eg 'riot-web'). |
||||
// Should correspond to a mapping configured in the configuration file for github issue reporting to work. |
||||
// (see R.string.bug_report_url for configured RS server) |
||||
return context.getString( |
||||
when (reportType) { |
||||
ReportType.AUTO_UISI_SENDER, |
||||
ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name |
||||
else -> R.string.bug_report_app_name |
||||
} |
||||
) |
||||
} |
||||
|
||||
// ============================================================================================================== |
||||
// Logcat management |
||||
// ============================================================================================================== |
||||
|
||||
/** |
||||
* Save the logcat. |
||||
* |
||||
* @param isErrorLogcat true to save the error logcat |
||||
* @return the file if the operation succeeds |
||||
*/ |
||||
private fun saveLogCat(isErrorLogcat: Boolean): File? { |
||||
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME) |
||||
|
||||
if (logCatErrFile.exists()) { |
||||
logCatErrFile.delete() |
||||
} |
||||
|
||||
try { |
||||
logCatErrFile.writer().use { |
||||
getLogCatError(it, isErrorLogcat) |
||||
} |
||||
|
||||
return compressFile(logCatErrFile) |
||||
} catch (error: OutOfMemoryError) { |
||||
Timber.e(error, "## saveLogCat() : fail to write logcat$error") |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## saveLogCat() : fail to write logcat$e") |
||||
} |
||||
|
||||
return null |
||||
} |
||||
|
||||
/** |
||||
* Retrieves the logs. |
||||
* |
||||
* @param streamWriter the stream writer |
||||
* @param isErrorLogCat true to save the error logs |
||||
*/ |
||||
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) { |
||||
val logcatProc: Process |
||||
|
||||
try { |
||||
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG) |
||||
} catch (e1: IOException) { |
||||
return |
||||
} |
||||
|
||||
try { |
||||
val separator = System.getProperty("line.separator") |
||||
logcatProc.inputStream |
||||
.reader() |
||||
.buffered(BUFFER_SIZE) |
||||
.forEachLine { line -> |
||||
streamWriter.append(line) |
||||
streamWriter.append(separator) |
||||
} |
||||
} catch (e: IOException) { |
||||
Timber.e(e, "getLog fails") |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,283 @@
@@ -0,0 +1,283 @@
|
||||
package io.element.android.x.features.rageshake.reporter; |
||||
|
||||
import java.io.IOException; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.UUID; |
||||
|
||||
import okhttp3.Headers; |
||||
import okhttp3.MediaType; |
||||
import okhttp3.RequestBody; |
||||
import okhttp3.internal.Util; |
||||
import okio.Buffer; |
||||
import okio.BufferedSink; |
||||
import okio.ByteString; |
||||
|
||||
// simplified version of MultipartBody (OkHttp 3.6.0)
|
||||
public class BugReporterMultipartBody extends RequestBody { |
||||
|
||||
/** |
||||
* Listener |
||||
*/ |
||||
public interface WriteListener { |
||||
/** |
||||
* Upload listener |
||||
* |
||||
* @param totalWritten total written bytes |
||||
* @param contentLength content length |
||||
*/ |
||||
void onWrite(long totalWritten, long contentLength); |
||||
} |
||||
|
||||
private static final MediaType FORM = MediaType.parse("multipart/form-data"); |
||||
|
||||
private static final byte[] COLONSPACE = {':', ' '}; |
||||
private static final byte[] CRLF = {'\r', '\n'}; |
||||
private static final byte[] DASHDASH = {'-', '-'}; |
||||
|
||||
private final ByteString mBoundary; |
||||
private final MediaType mContentType; |
||||
private final List<Part> mParts; |
||||
private long mContentLength = -1L; |
||||
|
||||
// listener
|
||||
private WriteListener mWriteListener; |
||||
|
||||
//
|
||||
private List<Long> mContentLengthSize = null; |
||||
|
||||
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) { |
||||
mBoundary = boundary; |
||||
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8()); |
||||
mParts = Util.toImmutableList(parts); |
||||
} |
||||
|
||||
@Override |
||||
public MediaType contentType() { |
||||
return mContentType; |
||||
} |
||||
|
||||
@Override |
||||
public long contentLength() throws IOException { |
||||
long result = mContentLength; |
||||
if (result != -1L) return result; |
||||
return mContentLength = writeOrCountBytes(null, true); |
||||
} |
||||
|
||||
@Override |
||||
public void writeTo(BufferedSink sink) throws IOException { |
||||
writeOrCountBytes(sink, false); |
||||
} |
||||
|
||||
/** |
||||
* Set the listener |
||||
* |
||||
* @param listener the |
||||
*/ |
||||
public void setWriteListener(WriteListener listener) { |
||||
mWriteListener = listener; |
||||
} |
||||
|
||||
/** |
||||
* Warn the listener that some bytes have been written |
||||
* |
||||
* @param totalWrittenBytes the total written bytes |
||||
*/ |
||||
private void onWrite(long totalWrittenBytes) { |
||||
if ((null != mWriteListener) && (mContentLength > 0)) { |
||||
mWriteListener.onWrite(totalWrittenBytes, mContentLength); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Either writes this request to {@code sink} or measures its content length. We have one method |
||||
* do double-duty to make sure the counting and content are consistent, particularly when it comes |
||||
* to awkward operations like measuring the encoded length of header strings, or the |
||||
* length-in-digits of an encoded integer. |
||||
*/ |
||||
private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException { |
||||
long byteCount = 0L; |
||||
|
||||
Buffer byteCountBuffer = null; |
||||
if (countBytes) { |
||||
sink = byteCountBuffer = new Buffer(); |
||||
mContentLengthSize = new ArrayList<>(); |
||||
} |
||||
|
||||
for (int p = 0, partCount = mParts.size(); p < partCount; p++) { |
||||
Part part = mParts.get(p); |
||||
Headers headers = part.headers; |
||||
RequestBody body = part.body; |
||||
|
||||
sink.write(DASHDASH); |
||||
sink.write(mBoundary); |
||||
sink.write(CRLF); |
||||
|
||||
if (headers != null) { |
||||
for (int h = 0, headerCount = headers.size(); h < headerCount; h++) { |
||||
sink.writeUtf8(headers.name(h)) |
||||
.write(COLONSPACE) |
||||
.writeUtf8(headers.value(h)) |
||||
.write(CRLF); |
||||
} |
||||
} |
||||
|
||||
MediaType contentType = body.contentType(); |
||||
if (contentType != null) { |
||||
sink.writeUtf8("Content-Type: ") |
||||
.writeUtf8(contentType.toString()) |
||||
.write(CRLF); |
||||
} |
||||
|
||||
int contentLength = (int) body.contentLength(); |
||||
if (contentLength != -1) { |
||||
sink.writeUtf8("Content-Length: ") |
||||
.writeUtf8(contentLength + "") |
||||
.write(CRLF); |
||||
} else if (countBytes) { |
||||
// We can't measure the body's size without the sizes of its components.
|
||||
byteCountBuffer.clear(); |
||||
return -1L; |
||||
} |
||||
|
||||
sink.write(CRLF); |
||||
|
||||
if (countBytes) { |
||||
byteCount += contentLength; |
||||
mContentLengthSize.add(byteCount); |
||||
} else { |
||||
body.writeTo(sink); |
||||
|
||||
// warn the listener of upload progress
|
||||
// sink.buffer().size() does not give the right value
|
||||
// assume that some data are popped
|
||||
if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) { |
||||
onWrite(mContentLengthSize.get(p)); |
||||
} |
||||
} |
||||
sink.write(CRLF); |
||||
} |
||||
|
||||
sink.write(DASHDASH); |
||||
sink.write(mBoundary); |
||||
sink.write(DASHDASH); |
||||
sink.write(CRLF); |
||||
|
||||
if (countBytes) { |
||||
byteCount += byteCountBuffer.size(); |
||||
byteCountBuffer.clear(); |
||||
} |
||||
|
||||
return byteCount; |
||||
} |
||||
|
||||
private static void appendQuotedString(StringBuilder target, String key) { |
||||
target.append('"'); |
||||
for (int i = 0, len = key.length(); i < len; i++) { |
||||
char ch = key.charAt(i); |
||||
switch (ch) { |
||||
case '\n': |
||||
target.append("%0A"); |
||||
break; |
||||
case '\r': |
||||
target.append("%0D"); |
||||
break; |
||||
case '"': |
||||
target.append("%22"); |
||||
break; |
||||
default: |
||||
target.append(ch); |
||||
break; |
||||
} |
||||
} |
||||
target.append('"'); |
||||
} |
||||
|
||||
public static final class Part { |
||||
public static Part create(Headers headers, RequestBody body) { |
||||
if (body == null) { |
||||
throw new NullPointerException("body == null"); |
||||
} |
||||
if (headers != null && headers.get("Content-Type") != null) { |
||||
throw new IllegalArgumentException("Unexpected header: Content-Type"); |
||||
} |
||||
if (headers != null && headers.get("Content-Length") != null) { |
||||
throw new IllegalArgumentException("Unexpected header: Content-Length"); |
||||
} |
||||
return new Part(headers, body); |
||||
} |
||||
|
||||
public static Part createFormData(String name, String value) { |
||||
return createFormData(name, null, RequestBody.create(value, null)); |
||||
} |
||||
|
||||
public static Part createFormData(String name, String filename, RequestBody body) { |
||||
if (name == null) { |
||||
throw new NullPointerException("name == null"); |
||||
} |
||||
StringBuilder disposition = new StringBuilder("form-data; name="); |
||||
appendQuotedString(disposition, name); |
||||
|
||||
if (filename != null) { |
||||
disposition.append("; filename="); |
||||
appendQuotedString(disposition, filename); |
||||
} |
||||
|
||||
return create(Headers.of("Content-Disposition", disposition.toString()), body); |
||||
} |
||||
|
||||
final Headers headers; |
||||
final RequestBody body; |
||||
|
||||
private Part(Headers headers, RequestBody body) { |
||||
this.headers = headers; |
||||
this.body = body; |
||||
} |
||||
} |
||||
|
||||
public static final class Builder { |
||||
private final ByteString boundary; |
||||
private final List<Part> parts = new ArrayList<>(); |
||||
|
||||
public Builder() { |
||||
this(UUID.randomUUID().toString()); |
||||
} |
||||
|
||||
public Builder(String boundary) { |
||||
this.boundary = ByteString.encodeUtf8(boundary); |
||||
} |
||||
|
||||
/** |
||||
* Add a form data part to the body. |
||||
*/ |
||||
public Builder addFormDataPart(String name, String value) { |
||||
return addPart(Part.createFormData(name, value)); |
||||
} |
||||
|
||||
/** |
||||
* Add a form data part to the body. |
||||
*/ |
||||
public Builder addFormDataPart(String name, String filename, RequestBody body) { |
||||
return addPart(Part.createFormData(name, filename, body)); |
||||
} |
||||
|
||||
/** |
||||
* Add a part to the body. |
||||
*/ |
||||
public Builder addPart(Part part) { |
||||
if (part == null) throw new NullPointerException("part == null"); |
||||
parts.add(part); |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* Assemble the specified parts into a request body. |
||||
*/ |
||||
public BugReporterMultipartBody build() { |
||||
if (parts.isEmpty()) { |
||||
throw new IllegalStateException("Multipart body must have at least one part."); |
||||
} |
||||
return new BugReporterMultipartBody(boundary, parts); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
package io.element.android.x.features.rageshake.reporter |
||||
|
||||
enum class ReportType { |
||||
BUG_REPORT, |
||||
SUGGESTION, |
||||
SPACE_BETA_FEEDBACK, |
||||
THREADS_BETA_FEEDBACK, |
||||
AUTO_UISI, |
||||
AUTO_UISI_SENDER, |
||||
} |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
package io.element.android.x.features.rageshake.screenshot |
||||
|
||||
import android.content.Context |
||||
import android.graphics.Bitmap |
||||
import io.element.android.x.core.bitmap.writeBitmap |
||||
import io.element.android.x.di.ApplicationContext |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
class ScreenshotHolder @Inject constructor( |
||||
@ApplicationContext private val context: Context, |
||||
) { |
||||
private val file = File(context.filesDir, "screenshot.png") |
||||
|
||||
fun writeBitmap(data: Bitmap) { |
||||
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85) |
||||
} |
||||
|
||||
fun getFile() = file.takeIf { it.exists() && it.length() > 0 } |
||||
|
||||
fun reset() { |
||||
file.delete() |
||||
} |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources> |
||||
|
||||
<!-- Rageshake configuration --> |
||||
<string name="bug_report_url" translatable="false">https://riot.im/bugreports/submit</string> |
||||
<string name="bug_report_app_name" translatable="false">riot-android</string> |
||||
<string name="bug_report_auto_uisi_app_name" translatable="false">element-auto-uisi</string> |
||||
|
||||
</resources> |
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
package io.element.android.x.features.login |
||||
|
||||
import org.junit.Assert.assertEquals |
||||
import org.junit.Test |
||||
|
||||
/** |
||||
* Example local unit test, which will execute on the development machine (host). |
||||
* |
||||
* See [testing documentation](http://d.android.com/tools/testing). |
||||
*/ |
||||
class ExampleUnitTest { |
||||
@Test |
||||
fun addition_isCorrect() { |
||||
assertEquals(4, 2 + 2) |
||||
} |
||||
} |
@ -1,2 +1,6 @@
@@ -1,2 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest /> |
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" /> |
||||
|
||||
</manifest> |
||||
|
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
package io.element.android.x.core.bitmap |
||||
|
||||
import android.graphics.Bitmap |
||||
import java.io.File |
||||
|
||||
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { |
||||
outputStream().use { out -> |
||||
bitmap.compress(format, quality, out) |
||||
out.flush() |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
package io.element.android.x.core.bool |
||||
|
||||
fun Boolean?.orTrue() = this ?: true |
||||
|
||||
fun Boolean?.orFalse() = this ?: false |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
package io.element.android.x.core.extensions |
||||
|
||||
import android.util.Patterns |
||||
|
||||
fun Boolean.toOnOff() = if (this) "ON" else "OFF" |
||||
|
||||
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block) |
||||
|
||||
/** |
||||
* Check if a CharSequence is an email. |
||||
*/ |
||||
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() |
||||
|
||||
// fun CharSequence.isMatrixId() = MatrixPatterns.isUserId(this.toString()) |
||||
|
||||
/** |
||||
* Return empty CharSequence if the CharSequence is null. |
||||
*/ |
||||
fun CharSequence?.orEmpty() = this ?: "" |
||||
|
||||
/** |
||||
* Check if a CharSequence is a phone number. |
||||
*/ |
||||
/* |
||||
fun CharSequence.isMsisdn(): Boolean { |
||||
return try { |
||||
PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null) |
||||
true |
||||
} catch (e: NumberParseException) { |
||||
false |
||||
} |
||||
} |
||||
*/ |
||||
|
||||
/** |
||||
* Useful to append a String at the end of a filename but before the extension if any |
||||
* Ex: |
||||
* - "file.txt".insertBeforeLast("_foo") will return "file_foo.txt" |
||||
* - "file".insertBeforeLast("_foo") will return "file_foo" |
||||
* - "fi.le.txt".insertBeforeLast("_foo") will return "fi.le_foo.txt" |
||||
* - null.insertBeforeLast("_foo") will return "_foo". |
||||
*/ |
||||
fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String { |
||||
if (this == null) return insert |
||||
val idx = lastIndexOf(delimiter) |
||||
return if (idx == -1) { |
||||
this + insert |
||||
} else { |
||||
replaceRange(idx, idx, insert) |
||||
} |
||||
} |
||||
|
||||
inline fun <reified R> Any?.takeAs(): R? { |
||||
return takeIf { it is R } as R? |
||||
} |
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
package io.element.android.x.core.file |
||||
|
||||
import timber.log.Timber |
||||
import java.io.File |
||||
import java.util.zip.GZIPOutputStream |
||||
|
||||
/** |
||||
* GZip a file. |
||||
* |
||||
* @param file the input file |
||||
* @return the gzipped file |
||||
*/ |
||||
fun compressFile(file: File): File? { |
||||
Timber.v("## compressFile() : compress ${file.name}") |
||||
|
||||
val dstFile = file.resolveSibling(file.name + ".gz") |
||||
|
||||
if (dstFile.exists()) { |
||||
dstFile.delete() |
||||
} |
||||
|
||||
return try { |
||||
GZIPOutputStream(dstFile.outputStream()).use { gos -> |
||||
file.inputStream().use { |
||||
it.copyTo(gos, 2048) |
||||
} |
||||
} |
||||
|
||||
Timber.v("## compressFile() : ${file.length()} compressed to ${dstFile.length()} bytes") |
||||
dstFile |
||||
} catch (e: Exception) { |
||||
Timber.e(e, "## compressFile() failed") |
||||
null |
||||
} catch (oom: OutOfMemoryError) { |
||||
Timber.e(oom, "## compressFile() failed") |
||||
null |
||||
} |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
/* |
||||
* Copyright 2020 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.x.core.hardware |
||||
|
||||
import android.content.Context |
||||
import android.os.Build |
||||
import android.os.VibrationEffect |
||||
import android.os.Vibrator |
||||
import androidx.core.content.getSystemService |
||||
|
||||
fun Context.vibrate(durationMillis: Long = 100) { |
||||
val vibrator = getSystemService<Vibrator>() ?: return |
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE)) |
||||
} else { |
||||
@Suppress("DEPRECATION") |
||||
vibrator.vibrate(durationMillis) |
||||
} |
||||
} |
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
/* |
||||
* Copyright 2020 The Matrix.org Foundation C.I.C. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.x.core.mimetype |
||||
|
||||
import io.element.android.x.core.bool.orFalse |
||||
|
||||
// The Android SDK does not provide constant for mime type, add some of them here |
||||
object MimeTypes { |
||||
const val Any: String = "*/*" |
||||
const val OctetStream = "application/octet-stream" |
||||
const val Apk = "application/vnd.android.package-archive" |
||||
|
||||
const val Images = "image/*" |
||||
|
||||
const val Png = "image/png" |
||||
const val BadJpg = "image/jpg" |
||||
const val Jpeg = "image/jpeg" |
||||
const val Gif = "image/gif" |
||||
|
||||
const val Ogg = "audio/ogg" |
||||
|
||||
const val PlainText = "text/plain" |
||||
|
||||
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this |
||||
|
||||
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse() |
||||
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse() |
||||
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse() |
||||
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse() |
||||
fun String?.isMimeTypeFile() = this?.startsWith("file/").orFalse() |
||||
fun String?.isMimeTypeText() = this?.startsWith("text/").orFalse() |
||||
fun String?.isMimeTypeAny() = this?.startsWith("*/").orFalse() |
||||
} |
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
package io.element.android.x.core.screenshot |
||||
|
||||
import android.app.Activity |
||||
import android.graphics.Bitmap |
||||
import android.graphics.Canvas |
||||
import android.os.Build |
||||
import android.os.Handler |
||||
import android.os.Looper |
||||
import android.view.PixelCopy |
||||
import android.view.View |
||||
|
||||
fun View.screenshot(bitmapCallback: (ImageResult) -> Unit) { |
||||
try { |
||||
val handler = Handler(Looper.getMainLooper()) |
||||
val bitmap = Bitmap.createBitmap( |
||||
width, |
||||
height, |
||||
Bitmap.Config.ARGB_8888, |
||||
) |
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
PixelCopy.request( |
||||
(this.context as Activity).window, |
||||
clipBounds, |
||||
bitmap, |
||||
{ |
||||
when (it) { |
||||
PixelCopy.SUCCESS -> { |
||||
bitmapCallback.invoke(ImageResult.Success(bitmap)) |
||||
} |
||||
else -> { |
||||
bitmapCallback.invoke(ImageResult.Error(Exception(it.toString()))) |
||||
} |
||||
} |
||||
}, |
||||
handler |
||||
) |
||||
} else { |
||||
handler.post { |
||||
val canvas = Canvas(bitmap) |
||||
.apply { |
||||
translate(-clipBounds.left.toFloat(), -clipBounds.top.toFloat()) |
||||
} |
||||
this.draw(canvas) |
||||
canvas.setBitmap(null) |
||||
bitmapCallback.invoke(ImageResult.Success(bitmap)) |
||||
} |
||||
} |
||||
} catch (e: Exception) { |
||||
bitmapCallback.invoke(ImageResult.Error(e)) |
||||
} |
||||
} |
||||
|
||||
sealed interface ImageResult { |
||||
data class Error(val exception: Exception) : ImageResult |
||||
data class Success(val data: Bitmap) : ImageResult |
||||
} |
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
package io.element.android.x.designsystem.components |
||||
|
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.material3.Checkbox |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
|
||||
@Composable |
||||
fun LabelledCheckbox( |
||||
checked: Boolean, |
||||
text: String, |
||||
modifier: Modifier = Modifier, |
||||
onCheckedChange: (Boolean) -> Unit = {}, |
||||
enabled: Boolean = true, |
||||
) { |
||||
Row( |
||||
modifier = modifier.fillMaxWidth(), |
||||
verticalAlignment = Alignment.CenterVertically |
||||
) { |
||||
Checkbox( |
||||
checked = checked, |
||||
onCheckedChange = onCheckedChange, |
||||
enabled = enabled, |
||||
) |
||||
Text(text = text) |
||||
} |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
fun LabelledCheckboxPreview() { |
||||
LabelledCheckbox( |
||||
checked = true, |
||||
text = "Some text", |
||||
) |
||||
} |
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
package io.element.android.x.designsystem.components.dialogs |
||||
|
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.material3.AlertDialog |
||||
import androidx.compose.material3.Button |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.x.element.resources.R as ElementR |
||||
|
||||
@Composable |
||||
fun ErrorDialog( |
||||
content: String, |
||||
modifier: Modifier = Modifier, |
||||
title: String = stringResource(id = ElementR.string.dialog_title_error), |
||||
submitText: String = stringResource(id = ElementR.string.ok), |
||||
onDismiss: () -> Unit = {}, |
||||
) { |
||||
AlertDialog( |
||||
modifier = modifier, |
||||
onDismissRequest = onDismiss, |
||||
title = { |
||||
Text(text = title) |
||||
}, |
||||
text = { |
||||
Text(content) |
||||
}, |
||||
confirmButton = { |
||||
Row( |
||||
modifier = Modifier.padding(all = 8.dp), |
||||
horizontalArrangement = Arrangement.Center |
||||
) { |
||||
Button( |
||||
modifier = Modifier.fillMaxWidth(), |
||||
onClick = { |
||||
onDismiss() |
||||
} |
||||
) { |
||||
Text(submitText) |
||||
} |
||||
} |
||||
}, |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
@Preview |
||||
fun ErrorDialogPreview() { |
||||
ErrorDialog( |
||||
content = "Content", |
||||
) |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.compose.ui.unit.dp |
||||
|
||||
internal val preferenceMinHeight = 80.dp |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.ColumnScope |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.unit.dp |
||||
|
||||
@Composable |
||||
fun PreferenceCategory( |
||||
title: String, |
||||
modifier: Modifier = Modifier, |
||||
content: @Composable ColumnScope.() -> Unit, |
||||
) { |
||||
Column( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.padding(horizontal = 16.dp) |
||||
) { |
||||
Text( |
||||
style = MaterialTheme.typography.titleSmall, |
||||
text = title |
||||
) |
||||
content() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview(showBackground = false) |
||||
fun PreferenceCategoryPreview() { |
||||
PreferenceCategory( |
||||
title = "Category title", |
||||
) { |
||||
PreferenceTextPreview() |
||||
PreferenceSwitchPreview() |
||||
PreferenceSlidePreview() |
||||
} |
||||
} |
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.ColumnScope |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.WindowInsets |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.statusBars |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.filled.ArrowBack |
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.material3.Icon |
||||
import androidx.compose.material3.IconButton |
||||
import androidx.compose.material3.Scaffold |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.material3.TopAppBar |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.style.TextOverflow |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.unit.sp |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun PreferenceScreen( |
||||
title: String, |
||||
modifier: Modifier = Modifier, |
||||
onBackPressed: () -> Unit = {}, |
||||
content: @Composable ColumnScope.() -> Unit, |
||||
) { |
||||
Scaffold( |
||||
modifier = modifier, |
||||
contentWindowInsets = WindowInsets.statusBars, |
||||
topBar = { |
||||
PreferenceTopAppBar( |
||||
title = title, |
||||
onBackPressed = onBackPressed, |
||||
) |
||||
}, |
||||
content = { |
||||
Column( |
||||
modifier = Modifier.padding(it) |
||||
) { |
||||
content() |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun PreferenceTopAppBar( |
||||
title: String, |
||||
modifier: Modifier = Modifier, |
||||
onBackPressed: () -> Unit = {}, |
||||
) { |
||||
TopAppBar( |
||||
modifier = modifier, |
||||
navigationIcon = { |
||||
IconButton(onClick = onBackPressed) { |
||||
Icon( |
||||
imageVector = Icons.Filled.ArrowBack, |
||||
contentDescription = "Back" |
||||
) |
||||
} |
||||
}, |
||||
title = { |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Text( |
||||
fontSize = 16.sp, |
||||
fontWeight = FontWeight.SemiBold, |
||||
text = title, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis |
||||
) |
||||
} |
||||
} |
||||
|
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
@Preview(showBackground = false) |
||||
fun PreferenceScreenPreview() { |
||||
PreferenceScreen( |
||||
title = "Preference screen" |
||||
) { |
||||
PreferenceCategoryPreview() |
||||
} |
||||
} |
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.annotation.FloatRange |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.defaultMinSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.Slider |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
|
||||
@Composable |
||||
fun PreferenceSlide( |
||||
title: String, |
||||
@FloatRange(0.0, 1.0) |
||||
value: Float, |
||||
modifier: Modifier = Modifier, |
||||
summary: String? = null, |
||||
steps: Int = 0, |
||||
onValueChange: (Float) -> Unit = {}, |
||||
) { |
||||
Box( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.defaultMinSize(minHeight = preferenceMinHeight), |
||||
contentAlignment = Alignment.CenterStart |
||||
) { |
||||
Column( |
||||
modifier = modifier |
||||
.fillMaxWidth(), |
||||
) { |
||||
Text( |
||||
modifier = Modifier.fillMaxWidth(), |
||||
style = MaterialTheme.typography.bodyLarge, |
||||
text = title |
||||
) |
||||
summary?.let { |
||||
Text( |
||||
modifier = Modifier.fillMaxWidth(), |
||||
style = MaterialTheme.typography.bodyMedium, |
||||
text = summary |
||||
) |
||||
} |
||||
Slider( |
||||
value = value, |
||||
steps = steps, |
||||
onValueChange = onValueChange |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview(showBackground = false) |
||||
fun PreferenceSlidePreview() { |
||||
PreferenceSlide( |
||||
title = "Slide", |
||||
summary = "Summary", |
||||
value = 0.75F |
||||
) |
||||
} |
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.defaultMinSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.material3.Checkbox |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
|
||||
@Composable |
||||
fun PreferenceSwitch( |
||||
title: String, |
||||
isChecked: Boolean, |
||||
modifier: Modifier = Modifier, |
||||
onCheckedChange: (Boolean) -> Unit = {}, |
||||
) { |
||||
Box( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.defaultMinSize(minHeight = preferenceMinHeight), |
||||
contentAlignment = Alignment.CenterStart |
||||
) { |
||||
Row( |
||||
modifier = modifier |
||||
.fillMaxWidth(), |
||||
verticalAlignment = Alignment.CenterVertically |
||||
) { |
||||
Text( |
||||
modifier = modifier |
||||
.weight(1f), |
||||
style = MaterialTheme.typography.bodyLarge, |
||||
text = title |
||||
) |
||||
Checkbox(checked = isChecked, onCheckedChange = onCheckedChange) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview(showBackground = false) |
||||
fun PreferenceSwitchPreview() { |
||||
PreferenceSwitch( |
||||
title = "Switch", |
||||
isChecked = true |
||||
) |
||||
} |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
package io.element.android.x.designsystem.components.preferences |
||||
|
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.defaultMinSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
|
||||
@Composable |
||||
fun PreferenceText( |
||||
title: String, |
||||
// TODO subtitle |
||||
modifier: Modifier = Modifier, |
||||
onClick: () -> Unit = {}, |
||||
) { |
||||
Box( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.defaultMinSize(minHeight = preferenceMinHeight) |
||||
.clickable { onClick() }, |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text( |
||||
modifier = Modifier |
||||
.fillMaxWidth(), |
||||
style = MaterialTheme.typography.bodyLarge, |
||||
text = title |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
@Preview(showBackground = false) |
||||
fun PreferenceTextPreview() { |
||||
PreferenceText( |
||||
title = "Title", |
||||
) |
||||
} |
Loading…
Reference in new issue