diff --git a/v4/app/src/main/AndroidManifest.xml b/v4/app/src/main/AndroidManifest.xml
index 46b35b18..f937920e 100644
--- a/v4/app/src/main/AndroidManifest.xml
+++ b/v4/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
+
+
+
+
+
+
+
diff --git a/v4/app/src/main/assets/settings.json b/v4/app/src/main/assets/settings.json
index 59a5590c..0cd65c6a 100644
--- a/v4/app/src/main/assets/settings.json
+++ b/v4/app/src/main/assets/settings.json
@@ -25,21 +25,21 @@
},
{
"title" : {
- "text" : "APP.V4.SYSTEM_STATUS"
+ "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE"
},
"link" : {
- "text" : "settings/status"
+ "text" : "settings/direction_color_preference"
+ },
+ "field":{
+ "field":"direction_color_preference"
}
},
{
"title" : {
- "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE"
+ "text" : "APP.V4.SYSTEM_STATUS"
},
"link" : {
- "text" : "settings/direction_color_preference"
- },
- "field":{
- "field":"direction_color_preference"
+ "text" : "settings/status"
}
}
]
diff --git a/v4/app/src/main/assets/settings_debug.json b/v4/app/src/main/assets/settings_debug.json
index 5a224188..349a3e9e 100644
--- a/v4/app/src/main/assets/settings_debug.json
+++ b/v4/app/src/main/assets/settings_debug.json
@@ -23,6 +23,17 @@
"field":"v4_theme"
}
},
+ {
+ "title" : {
+ "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE"
+ },
+ "link" : {
+ "text" : "settings/direction_color_preference"
+ },
+ "field":{
+ "field":"direction_color_preference"
+ }
+ },
{
"title" : {
"text" : "APP.V4.TRADING_NETWORK"
@@ -44,29 +55,26 @@
},
{
"title" : {
- "text" : "Feature Flag Overrides"
+ "text" : "APP.ISSUE_REPORT.SETTINGS_TITLE"
},
"link" : {
- "text" : "features"
+ "text" : "settings/report_issue"
}
},
{
"title" : {
- "text" : "Debug Settings"
+ "text" : "Feature Flag Overrides"
},
"link" : {
- "text" : "settings/debug"
+ "text" : "features"
}
},
{
"title" : {
- "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE"
+ "text" : "Debug Settings"
},
"link" : {
- "text" : "settings/direction_color_preference"
- },
- "field":{
- "field":"direction_color_preference"
+ "text" : "settings/debug"
}
}
]
diff --git a/v4/app/src/main/res/xml/file_paths.xml b/v4/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..06a785c4
--- /dev/null
+++ b/v4/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt b/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt
index b71076e3..d86e3d85 100644
--- a/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt
+++ b/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt
@@ -32,6 +32,7 @@ object ProfileRoutes {
const val help = "help"
const val rewards = "rewards"
const val debug_enable = "action/debug/enable"
+ const val report_issue = "settings/report_issue"
}
object NewsAlertsRoutes {
diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt
index e43f3ac2..5e2badf1 100644
--- a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt
+++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt
@@ -15,6 +15,7 @@ import exchange.dydx.trading.feature.profile.help.DydxHelpView
import exchange.dydx.trading.feature.profile.history.DydxHistoryView
import exchange.dydx.trading.feature.profile.keyexport.DydxKeyExportView
import exchange.dydx.trading.feature.profile.language.DydxLanguageView
+import exchange.dydx.trading.feature.profile.reportissue.DydxReportIssueView
import exchange.dydx.trading.feature.profile.rewards.DydxRewardsView
import exchange.dydx.trading.feature.profile.settings.DydxSettingsView
import exchange.dydx.trading.feature.profile.systemstatus.DydxSystemStatusView
@@ -161,4 +162,12 @@ fun NavGraphBuilder.profileGraph(
) { navBackStackEntry ->
DydxDebugEnableView.Content(Modifier)
}
+
+ dydxComposable(
+ router = appRouter,
+ route = ProfileRoutes.report_issue,
+ deepLinks = appRouter.deeplinks(ProfileRoutes.report_issue),
+ ) { navBackStackEntry ->
+ DydxReportIssueView.Content(Modifier)
+ }
}
diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt
new file mode 100644
index 00000000..d4402c44
--- /dev/null
+++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt
@@ -0,0 +1,67 @@
+package exchange.dydx.trading.feature.profile.reportissue
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.navigation.compose.hiltViewModel
+import exchange.dydx.abacus.protocols.LocalizerProtocol
+import exchange.dydx.platformui.designSystem.theme.ThemeFont
+import exchange.dydx.platformui.designSystem.theme.dydxDefault
+import exchange.dydx.platformui.designSystem.theme.themeFont
+import exchange.dydx.trading.common.component.DydxComponent
+import exchange.dydx.trading.common.compose.collectAsStateWithLifecycle
+import exchange.dydx.trading.common.theme.DydxThemedPreviewSurface
+import exchange.dydx.trading.common.theme.MockLocalizer
+
+@Preview
+@Composable
+fun Preview_DydxReportIssueView() {
+ DydxThemedPreviewSurface {
+ DydxReportIssueView.Content(Modifier, DydxReportIssueView.ViewState.preview)
+ }
+}
+
+object DydxReportIssueView : DydxComponent {
+ data class ViewState(
+ val localizer: LocalizerProtocol,
+ val text: String?,
+ ) {
+ companion object {
+ val preview = ViewState(
+ localizer = MockLocalizer(),
+ text = "1.0M",
+ )
+ }
+ }
+
+ @Composable
+ override fun Content(modifier: Modifier) {
+ val viewModel: DydxReportIssueViewModel = hiltViewModel()
+
+ val state = viewModel.state.collectAsStateWithLifecycle(initialValue = null).value
+ Content(modifier, state)
+ }
+
+ @Composable
+ fun Content(modifier: Modifier, state: ViewState?) {
+ if (state == null) {
+ return
+ }
+ Column(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ style = TextStyle.dydxDefault.themeFont(fontSize = ThemeFont.FontSize.extra),
+ text = state?.text ?: "",
+ )
+ }
+ }
+}
diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt
new file mode 100644
index 00000000..0aee74da
--- /dev/null
+++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt
@@ -0,0 +1,106 @@
+package exchange.dydx.trading.feature.profile.reportissue
+
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import androidx.core.content.FileProvider
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import exchange.dydx.abacus.protocols.LocalizerProtocol
+import exchange.dydx.platformui.components.PlatformInfo
+import exchange.dydx.trading.common.DydxViewModel
+import exchange.dydx.trading.common.navigation.DydxRouter
+import exchange.dydx.utilities.utils.EmailUtils
+import exchange.dydx.utilities.utils.FileUtils
+import exchange.dydx.utilities.utils.LogCatReader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.withContext
+import java.io.File
+import javax.inject.Inject
+
+@HiltViewModel
+class DydxReportIssueViewModel @Inject constructor(
+ private val localizer: LocalizerProtocol,
+ private val router: DydxRouter,
+ @ApplicationContext private val context: Context,
+ val platformInfo: PlatformInfo,
+) : ViewModel(), DydxViewModel {
+
+ private val textFlow = MutableStateFlow("")
+
+ val state: Flow = textFlow.map { text ->
+ createViewState(text)
+ }
+
+ init {
+ textFlow.value = localizer.localize("APP.ISSUE_REPORT.LOADING_TITLE")
+
+ viewModelScope.launch {
+ var logUri: Uri? = null
+ withContext(Dispatchers.IO) {
+ // add a delay to show the loading text
+ kotlinx.coroutines.delay(500)
+ logUri = createLog()
+ }
+ if (logUri != null) {
+ textFlow.value = localizer.localize("APP.ISSUE_REPORT.LOADING_COMPLETED_TITLE")
+ EmailUtils.sendEmailWithAttachment(
+ context = context,
+ fileUri = logUri,
+ email = "",
+ subject = localizer.localize("APP.ISSUE_REPORT.EMAIL_SUBJECT"),
+ body = localizer.localize("APP.ISSUE_REPORT.EMAIL_BODY"),
+ mimeType = "application/x-zip",
+ chooserTitle = localizer.localize("APP.ISSUE_REPORT.CHOOSER_TITLE"),
+ )
+ } else {
+ val error = localizer.localize("APP.ISSUE_REPORT.LOADING_ERROR_TITLE")
+ textFlow.value = error
+ platformInfo.show(message = error)
+ }
+
+ router.navigateBack()
+ }
+ }
+
+ private fun createViewState(text: String): DydxReportIssueView.ViewState {
+ return DydxReportIssueView.ViewState(
+ localizer = localizer,
+ text = text,
+ )
+ }
+
+ private fun createLog(): Uri? {
+ val file =
+ File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "dydx_app.log")
+ if (!LogCatReader.saveLogCatToFile(file)) {
+ return null
+ }
+ val zipFile = createZipFile(context, file)
+ file.delete()
+ return if (zipFile != null) {
+ FileProvider.getUriForFile(context, context.packageName, zipFile)
+ } else {
+ null
+ }
+ }
+
+ private fun createZipFile(context: Context, file: File): File? {
+ val fileName = file.name
+ val zipFileName = "$fileName.zip"
+ val zipFilePath =
+ File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), zipFileName)
+ return if (FileUtils.compressFile(context, file.absolutePath, zipFilePath.absolutePath)) {
+ zipFilePath
+ } else {
+ null
+ }
+ }
+}
diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt
new file mode 100644
index 00000000..700075e9
--- /dev/null
+++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt
@@ -0,0 +1,29 @@
+package exchange.dydx.utilities.utils
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+
+object EmailUtils {
+ fun sendEmailWithAttachment(
+ context: Context,
+ fileUri: Uri?,
+ email: String,
+ subject: String?,
+ body: String?,
+ mimeType: String?,
+ chooserTitle: String?
+ ) {
+ val emailIntent = Intent(Intent.ACTION_SEND)
+ emailIntent.setType(mimeType)
+ emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
+ emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject)
+ emailIntent.putExtra(Intent.EXTRA_TEXT, body)
+ emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
+ val chooser = Intent.createChooser(emailIntent, chooserTitle)
+ if (chooser.resolveActivity(context.packageManager) != null) {
+ chooser.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooser)
+ }
+ }
+}
diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt
index 3df25a3e..62cb9b13 100644
--- a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt
+++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt
@@ -2,8 +2,18 @@ package exchange.dydx.utilities.utils
import android.content.Context
import android.util.Log
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
object FileUtils {
+
+ private const val TAG = "FileUtils"
+
fun loadFromAssets(context: Context, fileName: String): String? {
return try {
val manager = context.assets
@@ -14,8 +24,37 @@ object FileUtils {
inputStream.close()
String(buffer)
} catch (e: Exception) {
- Log.e("FileUtils", "error: $e")
+ Log.e(TAG, "error: $e")
+ e.printStackTrace()
null
}
}
+
+ fun compressFile(context: Context, sourceFilePath: String, compressedFilePath: String): Boolean {
+ val bufferSize = 1024
+ try {
+ val sourceFile = File(sourceFilePath)
+ val compressedFile = File(compressedFilePath)
+
+ val fileInputStream = FileInputStream(sourceFile)
+ val zipOutputStream = ZipOutputStream(BufferedOutputStream(FileOutputStream(compressedFile)))
+ val zipEntry = ZipEntry(sourceFile.name)
+ zipOutputStream.putNextEntry(zipEntry)
+
+ val buffer = ByteArray(bufferSize)
+ var length: Int
+ while (fileInputStream.read(buffer).also { length = it } > 0) {
+ zipOutputStream.write(buffer, 0, length)
+ }
+
+ zipOutputStream.closeEntry()
+ zipOutputStream.close()
+ fileInputStream.close()
+ return true
+ } catch (e: IOException) {
+ Log.e(TAG, "Error compressing file: $e")
+ e.printStackTrace()
+ return false
+ }
+ }
}
diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt
new file mode 100644
index 00000000..f168497d
--- /dev/null
+++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt
@@ -0,0 +1,38 @@
+package exchange.dydx.utilities.utils
+
+import android.util.Log
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+import java.io.IOException
+
+object LogCatReader {
+
+ private const val TAG = "LogCatReader"
+
+ /**
+ * Reads the logcat and stores the log in a file.
+ */
+ fun saveLogCatToFile(logFile: File): Boolean {
+ try {
+ val process = Runtime.getRuntime().exec("logcat -d")
+ val reader = process.inputStream.bufferedReader()
+ val writer = BufferedWriter(FileWriter(logFile))
+
+ var line: String? = reader.readLine()
+ while (line != null) {
+ writer.write(line)
+ writer.newLine()
+ line = reader.readLine()
+ }
+
+ writer.close()
+ reader.close()
+ process.destroy()
+ return true
+ } catch (e: IOException) {
+ Log.e(TAG, "saveLogCatToFile: $e")
+ return false
+ }
+ }
+}