Merge pull request #222 from vector-im/feature/bma/networkModule

Add network module
This commit is contained in:
Benoit Marty 2023-03-17 15:30:27 +01:00 committed by GitHub
commit f1b5fed255
25 changed files with 621 additions and 313 deletions

View file

@ -219,6 +219,9 @@ dependencies {
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.coil) implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:logging-interceptor")
implementation(libs.dagger) implementation(libs.dagger)
kapt(libs.dagger.compiler) kapt(libs.dagger.compiler)

View file

@ -21,15 +21,19 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.x.BuildConfig
import io.element.android.x.R
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -48,6 +52,22 @@ object AppModule {
return MainScope() + CoroutineName("ElementX Scope") return MainScope() + CoroutineName("ElementX Scope")
} }
@Provides
@SingleIn(AppScope::class)
fun providesBuildMeta(@ApplicationContext context: Context) = BuildMeta(
isDebug = BuildConfig.DEBUG,
applicationName = context.getString(R.string.app_name),
applicationId = BuildConfig.APPLICATION_ID,
lowPrivacyLoggingEnabled = false, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
versionName = BuildConfig.VERSION_NAME,
gitRevision = "TODO", // BuildConfig.GIT_REVISION,
gitRevisionDate = "TODO", // BuildConfig.GIT_REVISION_DATE,
gitBranchName = "TODO", // BuildConfig.GIT_BRANCH_NAME,
flavorDescription = "TODO", // BuildConfig.FLAVOR_DESCRIPTION,
flavorShortDescription = "TODO", // BuildConfig.SHORT_FLAVOR_DESCRIPTION,
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
)
@Provides @Provides
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers { fun providesCoroutineDispatchers(): CoroutineDispatchers {

View file

@ -16,15 +16,10 @@
package io.element.android.features.rageshake.api.reporter package io.element.android.features.rageshake.api.reporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType
import kotlinx.coroutines.CoroutineScope
interface BugReporter { interface BugReporter {
/** /**
* Send a bug report. * Send a bug report.
* *
* @param coroutineScope The coroutine scope
* @param reportType The report type (bug, suggestion, feedback) * @param reportType The report type (bug, suggestion, feedback)
* @param withDevicesLogs true to include the device log * @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs * @param withCrashLogs true to include the crash logs
@ -36,8 +31,7 @@ interface BugReporter {
* @param customFields fields which will be sent with the report * @param customFields fields which will be sent with the report
* @param listener the listener * @param listener the listener
*/ */
fun sendBugReport( suspend fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType, reportType: ReportType,
withDevicesLogs: Boolean, withDevicesLogs: Boolean,
withCrashLogs: Boolean, withCrashLogs: Boolean,

View file

@ -43,6 +43,8 @@ dependencies {
api(libs.squareup.seismic) api(libs.squareup.seismic)
api(projects.features.rageshake.api) api(projects.features.rageshake.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:okhttp")
implementation(libs.coil) implementation(libs.coil)
implementation(libs.coil.compose) implementation(libs.coil.compose)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -19,6 +19,8 @@ package io.element.android.features.rageshake.impl.bugreport
sealed interface BugReportEvents { sealed interface BugReportEvents {
object SendBugReport : BugReportEvents object SendBugReport : BugReportEvents
object ResetAll : BugReportEvents object ResetAll : BugReportEvents
object ClearError : BugReportEvents
data class SetDescription(val description: String) : BugReportEvents data class SetDescription(val description: String) : BugReportEvents
data class SetSendLog(val sendLog: Boolean) : BugReportEvents data class SetSendLog(val sendLog: Boolean) : BugReportEvents
data class SetSendCrashLog(val sendCrashlog: Boolean) : BugReportEvents data class SetSendCrashLog(val sendCrashlog: Boolean) : BugReportEvents

View file

@ -109,6 +109,10 @@ class BugReportPresenter @Inject constructor(
is BugReportEvents.SetSendScreenshot -> updateFormState(formState) { is BugReportEvents.SetSendScreenshot -> updateFormState(formState) {
copy(sendScreenshot = event.sendScreenshot) copy(sendScreenshot = event.sendScreenshot)
} }
BugReportEvents.ClearError -> {
sendingProgress.value = 0f
sendingAction.value = Async.Uninitialized
}
} }
} }
@ -132,7 +136,6 @@ class BugReportPresenter @Inject constructor(
listener: BugReporterListener, listener: BugReporterListener,
) = launch { ) = launch {
bugReporter.sendBugReport( bugReporter.sendBugReport(
coroutineScope = this,
reportType = ReportType.BUG_REPORT, reportType = ReportType.BUG_REPORT,
withDevicesLogs = formState.sendLogs, withDevicesLogs = formState.sendLogs,
withCrashLogs = hasCrashLogs && formState.sendCrashLogs, withCrashLogs = hasCrashLogs && formState.sendCrashLogs,

View file

@ -197,13 +197,14 @@ fun BugReportView(
} }
when (state.sending) { when (state.sending) {
is Async.Loading -> { is Async.Loading -> {
// Indeterminate indicator, to avoid the freeze effect if the connection takes time to initialize.
CircularProgressIndicator( CircularProgressIndicator(
progress = state.sendingProgress,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
} }
is Async.Failure -> ErrorDialog( is Async.Failure -> ErrorDialog(
content = state.sending.error.toString(), content = state.sending.error.toString(),
onDismiss = { state.eventSink(BugReportEvents.ClearError) }
) )
else -> Unit else -> Unit
} }

View file

@ -21,13 +21,13 @@ import android.os.Build
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.features.rageshake.impl.R
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.impl.R
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.libraries.androidutils.file.compressFile import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -35,9 +35,7 @@ import io.element.android.libraries.core.extensions.toOnOff
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -64,6 +62,7 @@ class DefaultBugReporter @Inject constructor(
private val screenshotHolder: ScreenshotHolder, private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore, private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val okHttpClient: OkHttpClient,
/* /*
private val activeSessionHolder: ActiveSessionHolder, private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider, private val versionProvider: VersionProvider,
@ -88,9 +87,6 @@ class DefaultBugReporter @Inject constructor(
private const val BUFFER_SIZE = 1024 * 1024 * 50 private const val BUFFER_SIZE = 1024 * 1024 * 50
} }
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call // the pending bug report call
private var mBugReportCall: Call? = null private var mBugReportCall: Call? = null
@ -118,7 +114,6 @@ class DefaultBugReporter @Inject constructor(
/** /**
* Send a bug report. * Send a bug report.
* *
* @param coroutineScope The coroutine scope
* @param reportType The report type (bug, suggestion, feedback) * @param reportType The report type (bug, suggestion, feedback)
* @param withDevicesLogs true to include the device log * @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs * @param withCrashLogs true to include the crash logs
@ -130,8 +125,7 @@ class DefaultBugReporter @Inject constructor(
* @param customFields fields which will be sent with the report * @param customFields fields which will be sent with the report
* @param listener the listener * @param listener the listener
*/ */
override fun sendBugReport( override suspend fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType, reportType: ReportType,
withDevicesLogs: Boolean, withDevicesLogs: Boolean,
withCrashLogs: Boolean, withCrashLogs: Boolean,
@ -146,282 +140,280 @@ class DefaultBugReporter @Inject constructor(
// enumerate files to delete // enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList() val mBugReportFiles: MutableList<File> = ArrayList()
coroutineScope.launch { var serverError: String? = null
var serverError: String? = null var reportURL: String? = null
var reportURL: String? = null withContext(coroutineDispatchers.io) {
withContext(coroutineDispatchers.io) { var bugDescription = theBugDescription
var bugDescription = theBugDescription val crashCallStack = crashDataStore.crashInfo().first()
val crashCallStack = crashDataStore.crashInfo().first()
if (crashCallStack.isNotEmpty() && withCrashLogs) { if (crashCallStack.isNotEmpty() && withCrashLogs) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack bugDescription += crashCallStack
} }
val gzippedFiles = ArrayList<File>() val gzippedFiles = ArrayList<File>()
val vectorFileLogger = VectorFileLogger.getFromTimber() val vectorFileLogger = VectorFileLogger.getFromTimber()
if (withDevicesLogs && vectorFileLogger != null) { if (withDevicesLogs && vectorFileLogger != null) {
val files = vectorFileLogger.getLogFiles() val files = vectorFileLogger.getLogFiles()
files.mapNotNullTo(gzippedFiles) { f -> files.mapNotNullTo(gzippedFiles) { f ->
if (!mIsCancelled) { if (!mIsCancelled) {
compressFile(f) 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 -> bugDescription
ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[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.getFileUri()
?.toUri()
?.toFile()
?.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)
// 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 { } else {
/* null
reportURL = response?.body?.string()?.let { stringBody ->
adapter.fromJson(stringBody)?.get("report_url")?.toString()
}
*/
} }
} }
} }
withContext(coroutineDispatchers.main) { if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
mBugReportCall = null val gzippedLogcat = saveLogCat(false)
// delete when the bug report has been successfully sent if (null != gzippedLogcat) {
for (file in mBugReportFiles) { if (gzippedFiles.size == 0) {
file.safeDelete() 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 -> bugDescription
ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription"
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> bugDescription
} }
if (null != listener) { // build the multi part request
try { val builder = BugReporterMultipartBody.Builder()
if (mIsCancelled) { .addFormDataPart("text", text)
listener.onUploadCancelled() .addFormDataPart("app", rageShakeAppNameForReport(reportType))
} else if (null == serverError) { // .addFormDataPart("user_agent", matrix.getUserAgent())
listener.onUploadSucceed(reportURL) .addFormDataPart("user_id", userId)
} else { .addFormDataPart("can_contact", canContact.toString())
listener.onUploadFailed(serverError) .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)
} }
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed")
} }
// 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.getFileUri()
?.toUri()
?.toFile()
?.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)
// 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 = okHttpClient.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(coroutineDispatchers.main) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.safeDelete()
}
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")
} }
} }
} }
@ -492,9 +484,9 @@ class DefaultBugReporter @Inject constructor(
return compressFile(logCatErrFile) return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) { } catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error") Timber.e(error, "## saveLogCat() : fail to write logcat OOM")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e") Timber.e(e, "## saveLogCat() : fail to write logcat")
} }
return null return null

View file

@ -222,6 +222,11 @@ class BugReportPresenterTest {
// Failure // Failure
assertThat(awaitItem().sendingProgress).isEqualTo(0f) assertThat(awaitItem().sendingProgress).isEqualTo(0f)
assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON) assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON)
// Reset failure
initialState.eventSink.invoke(BugReportEvents.ClearError)
val lastItem = awaitItem()
assertThat(lastItem.sendingProgress).isEqualTo(0f)
assertThat(lastItem.sending).isInstanceOf(Async.Uninitialized::class.java)
} }
} }

View file

@ -20,13 +20,10 @@ import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.libraries.matrix.test.A_FAILURE_REASON import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter {
override fun sendBugReport( override suspend fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType, reportType: ReportType,
withDevicesLogs: Boolean, withDevicesLogs: Boolean,
withCrashLogs: Boolean, withCrashLogs: Boolean,
@ -38,27 +35,25 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes
customFields: Map<String, String>?, customFields: Map<String, String>?,
listener: BugReporterListener?, listener: BugReporterListener?,
) { ) {
coroutineScope.launch { delay(100)
delay(100) listener?.onProgress(0)
listener?.onProgress(0) delay(100)
delay(100) listener?.onProgress(50)
listener?.onProgress(50) delay(100)
delay(100) when (mode) {
when (mode) { FakeBugReporterMode.Success -> Unit
FakeBugReporterMode.Success -> Unit FakeBugReporterMode.Failure -> {
FakeBugReporterMode.Failure -> { listener?.onUploadFailed(A_FAILURE_REASON)
listener?.onUploadFailed(A_FAILURE_REASON) return
return@launch }
} FakeBugReporterMode.Cancel -> {
FakeBugReporterMode.Cancel -> { listener?.onUploadCancelled()
listener?.onUploadCancelled() return
return@launch
}
} }
listener?.onProgress(100)
delay(100)
listener?.onUploadSucceed(null)
} }
listener?.onProgress(100)
delay(100)
listener?.onUploadSucceed(null)
} }
} }

View file

@ -92,6 +92,11 @@ accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayo
# Libraries # Libraries
squareup_seismic = "com.squareup:seismic:1.0.3" squareup_seismic = "com.squareup:seismic:1.0.3"
# network
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.10.0"
network_retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
network_retrofit_converter_serialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
# Test # Test
test_core = { module = "androidx.test:core", version.ref = "test_core" } test_core = { module = "androidx.test:core", version.ref = "test_core" }
test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }

View file

@ -1,4 +1,3 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2022 New Vector Ltd
* *
@ -29,6 +28,8 @@ java {
dependencies { dependencies {
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:logging-interceptor")
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 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.libraries.core.meta
import okhttp3.logging.HttpLoggingInterceptor
data class BuildMeta(
val isDebug: Boolean,
val applicationName: String,
val applicationId: String,
val lowPrivacyLoggingEnabled: Boolean,
val versionName: String,
val gitRevision: String,
val gitRevisionDate: String,
val gitBranchName: String,
val flavorDescription: String,
val flavorShortDescription: String,
val okHttpLoggingLevel: HttpLoggingInterceptor.Level,
)

View file

@ -16,6 +16,8 @@
package io.element.android.libraries.designsystem.theme.components package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -23,8 +25,11 @@ import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@Composable @Composable
fun CircularProgressIndicator( fun CircularProgressIndicator(
@ -54,6 +59,27 @@ fun CircularProgressIndicator(
) )
} }
@Preview
@Composable
internal fun CircularProgressIndicatorLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun CircularProgressIndicatorDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Indeterminate progress
CircularProgressIndicator(
)
// Fixed progress
CircularProgressIndicator(
progress = 0.75F
)
}
}
@Composable @Composable
fun ButtonCircularProgressIndicator( fun ButtonCircularProgressIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -21,15 +21,18 @@ import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
class LoggedInImageLoaderFactory @Inject constructor( class LoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val okHttpClient: OkHttpClient,
) : ImageLoaderFactory { ) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
return ImageLoader return ImageLoader
.Builder(context) .Builder(context)
.okHttpClient(okHttpClient)
.components { .components {
add(AvatarKeyer()) add(AvatarKeyer())
add(MediaKeyer()) add(MediaKeyer())
@ -42,10 +45,12 @@ class LoggedInImageLoaderFactory @Inject constructor(
class NotLoggedInImageLoaderFactory @Inject constructor( class NotLoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
) : ImageLoaderFactory { ) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
return ImageLoader return ImageLoader
.Builder(context) .Builder(context)
.okHttpClient(okHttpClient)
.build() .build()
} }
} }

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 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.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.network"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
implementation(libs.network.retrofit)
implementation(libs.network.retrofit.converter.serialization)
implementation(libs.serialization.json)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 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.libraries.network
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import okhttp3.OkHttpClient
import okhttp3.Protocol
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
import java.util.concurrent.TimeUnit
import okhttp3.logging.HttpLoggingInterceptor
@Module
@ContributesTo(AppScope::class)
object NetworkModule {
@Provides
@JvmStatic
fun providesHttpLoggingInterceptor(buildMeta: BuildMeta): HttpLoggingInterceptor {
val logger = FormattedJsonHttpLogger(buildMeta.okHttpLoggingLevel)
val interceptor = HttpLoggingInterceptor(logger)
interceptor.level = buildMeta.okHttpLoggingLevel
return interceptor
}
@Provides
@SingleIn(AppScope::class)
fun providesOkHttpClient(
httpLoggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
// workaround for #4669
.protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(httpLoggingInterceptor)
.build()
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 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.libraries.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Lazy
import io.element.android.libraries.core.uri.ensureTrailingSlash
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import javax.inject.Inject
class RetrofitFactory @Inject constructor(
private val okHttpClient: Lazy<OkHttpClient>,
) {
fun create(baseUrl: String): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(Json.asConverterFactory(contentType))
.callFactory { request -> okHttpClient.get().newCall(request) }
.build()
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2023 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.libraries.network.interceptors
import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
internal class FormattedJsonHttpLogger(
private val level: HttpLoggingInterceptor.Level
) : HttpLoggingInterceptor.Logger {
companion object {
private const val INDENT_SPACE = 2
}
/**
* Log the message and try to log it again as a JSON formatted string.
* Note: it can consume a lot of memory but it is only in DEBUG mode.
*
* @param message
*/
@Synchronized
override fun log(message: String) {
Timber.v(message)
// Try to log formatted Json only if there is a chance that [message] contains Json.
// It can be only the case if we log the bodies of Http requests.
if (level != HttpLoggingInterceptor.Level.BODY) return
if (message.startsWith("{")) {
// JSON Detected
try {
val o = JSONObject(message)
logJson(o.toString(INDENT_SPACE))
} catch (e: JSONException) {
// Finally this is not a JSON string...
Timber.e(e)
}
} else if (message.startsWith("[")) {
// JSON Array detected
try {
val o = JSONArray(message)
logJson(o.toString(INDENT_SPACE))
} catch (e: JSONException) {
// Finally not JSON...
Timber.e(e)
}
}
// Else not a json string to log
}
private fun logJson(formattedJson: String) {
formattedJson
.lines()
.dropLastWhile { it.isEmpty() }
.forEach { Timber.v(it) }
}
}

View file

@ -55,6 +55,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:designsystem")) implementation(project(":libraries:designsystem"))
implementation(project(":libraries:matrix:impl")) implementation(project(":libraries:matrix:impl"))
implementation(project(":libraries:matrixui")) implementation(project(":libraries:matrixui"))
implementation(project(":libraries:network"))
implementation(project(":libraries:core")) implementation(project(":libraries:core"))
implementation(project(":libraries:architecture")) implementation(project(":libraries:architecture"))
implementation(project(":libraries:dateformatter:impl")) implementation(project(":libraries:dateformatter:impl"))

View file

@ -49,6 +49,7 @@ include(":libraries:dateformatter:api")
include(":libraries:dateformatter:impl") include(":libraries:dateformatter:impl")
include(":libraries:dateformatter:test") include(":libraries:dateformatter:test")
include(":libraries:elementresources") include(":libraries:elementresources")
include(":libraries:network")
include(":libraries:ui-strings") include(":libraries:ui-strings")
include(":libraries:testtags") include(":libraries:testtags")
include(":libraries:designsystem") include(":libraries:designsystem")

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5c34404a33be8eb234025442da98fc85e29236769c200bd0e40cc2cf8f9db3e4 oid sha256:9b432f300926890ddc7dcc59051b3b31a2a1185ba1d527d776378547433905d9
size 52604 size 52789

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e628e65234a8632350cdc9ab014b36f3c2bd9e35977bc6d919314a32c2f8a1cf oid sha256:08cb1e38ee31bf4931d3835d471e61e5cc51d8b6ca5fca3d3044ba85686777ea
size 50965 size 51075

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a100c468dce7b2f7d568dcf0890f680c8f46c063fe0f03f20aed90ed0ad12565
size 6630

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7e7e2d22e590304ff9921505bce7eca67846299838a6345dc1d27047f2db33c
size 6518