feat: Properly handle api unreachable scenarios
This commit is contained in:
parent
fb3d7785ba
commit
b9d64cc1cf
|
@ -0,0 +1,39 @@
|
|||
package com.p_vacho.neat_calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.p_vacho.neat_calendar.activities.ApiUnreachableActivity
|
||||
import com.p_vacho.neat_calendar.util.ExceptionSerializer
|
||||
import com.p_vacho.neat_calendar.util.SerializedException
|
||||
import retrofit2.HttpException
|
||||
|
||||
class GlobalExceptionHandler(
|
||||
private val context: Context
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
if (e is HttpException && e.code() == 503) {
|
||||
val errorBody = e.response()!!.errorBody()!!.string()
|
||||
val exception = ExceptionSerializer.deserialize(errorBody)
|
||||
Log.e("GlobalExceptionHandler", "Deserialized HTTP 503 Exception")
|
||||
Log.e("GlobalExceptionHandler", "Exception Type: ${exception.type}")
|
||||
Log.e("GlobalExceptionHandler", "Message: ${exception.message}")
|
||||
Log.e("GlobalExceptionHandler", "Stacktrace:\n${exception.stacktrace.joinToString("\n")}")
|
||||
|
||||
navigateToApiUnreachableActivity()
|
||||
} else {
|
||||
// Let the default handler manage other exceptions
|
||||
defaultHandler?.uncaughtException(t, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToApiUnreachableActivity() {
|
||||
val intent = Intent(context, ApiUnreachableActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
package com.p_vacho.neat_calendar
|
||||
|
||||
import android.app.Application
|
||||
import com.jakewharton.threetenabp.AndroidThreeTen
|
||||
import com.p_vacho.neat_calendar.api.RetrofitClient
|
||||
import com.p_vacho.neat_calendar.util.auth.AuthRepository
|
||||
import com.p_vacho.neat_calendar.util.auth.TokenManager
|
||||
import com.jakewharton.threetenabp.AndroidThreeTen
|
||||
|
||||
|
||||
class MyApplication : Application() {
|
||||
lateinit var tokenManager: TokenManager
|
||||
|
@ -14,6 +15,10 @@ class MyApplication : Application() {
|
|||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
// Set up a global exception handler
|
||||
// (Do this first, so we capture any exceptions that may occur from the below calls too)
|
||||
Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionHandler(this))
|
||||
|
||||
super.onCreate()
|
||||
|
||||
// Initialize the timezone information
|
||||
|
|
|
@ -2,34 +2,47 @@ package com.p_vacho.neat_calendar.activities
|
|||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.p_vacho.neat_calendar.R
|
||||
import com.p_vacho.neat_calendar.api.RetrofitClient
|
||||
import com.p_vacho.neat_calendar.util.ExceptionSerializer
|
||||
import com.p_vacho.neat_calendar.util.SerializedException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import retrofit2.HttpException
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class ApiUnreachableActivity : AppCompatActivity() {
|
||||
private var retryCount = 0
|
||||
private var exception: IOException? = null
|
||||
private var exception: SerializedException? = null
|
||||
private var isStackTraceVisible = false
|
||||
|
||||
companion object {
|
||||
// Keep track of whether this activity is already running to avoid starting
|
||||
// duplicate instances of this activity for each failed api request.
|
||||
var isActive: Boolean = false
|
||||
}
|
||||
|
||||
// UI variables
|
||||
private lateinit var retryButton: Button
|
||||
private lateinit var closeButton: Button
|
||||
private lateinit var toggleStackTraceLink: TextView
|
||||
private lateinit var retryCountText: TextView
|
||||
private lateinit var devErrorDetailsText: TextView
|
||||
private lateinit var stackTraceContainer: LinearLayout
|
||||
private lateinit var stackTraceText: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
isActive = true
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContentView(R.layout.activity_api_unreachable)
|
||||
|
@ -40,59 +53,145 @@ class ApiUnreachableActivity : AppCompatActivity() {
|
|||
insets
|
||||
}
|
||||
|
||||
val retryButton = findViewById<Button>(R.id.retryButton)
|
||||
val closeButton = findViewById<Button>(R.id.closeButton)
|
||||
val retryCountText = findViewById<TextView>(R.id.retryCount)
|
||||
val devErrorDetailsText = findViewById<TextView>(R.id.devErrorDetailsText)
|
||||
initializeUI()
|
||||
attemptImmediateReconnect()
|
||||
}
|
||||
|
||||
retryButton.setOnClickListener {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
retryCount++
|
||||
exception = withContext(Dispatchers.IO) { RetrofitClient.performPingWithoutReachabilityCheck() }
|
||||
private fun initializeUI() {
|
||||
retryButton = findViewById(R.id.retryButton)
|
||||
closeButton = findViewById(R.id.closeButton)
|
||||
toggleStackTraceLink = findViewById(R.id.toggleStackTraceLink)
|
||||
retryCountText = findViewById(R.id.retryCount)
|
||||
devErrorDetailsText = findViewById(R.id.devErrorDetailsText)
|
||||
stackTraceContainer = findViewById(R.id.stackTraceContainer)
|
||||
stackTraceText = findViewById(R.id.stackTraceText)
|
||||
|
||||
if (exception == null) {
|
||||
navigateToMainActivity()
|
||||
} else {
|
||||
retryCountText.text = getString(R.string.retry_attempts_message, retryCount)
|
||||
retryButton.setOnClickListener { onRetryClicked() }
|
||||
closeButton.setOnClickListener { onCloseClicked() }
|
||||
toggleStackTraceLink.setOnClickListener { onToggleStackTraceClicked() }
|
||||
}
|
||||
|
||||
devErrorDetailsText.text = buildString {
|
||||
append(getString(R.string.error_type, exception!!.javaClass.simpleName))
|
||||
append("\n")
|
||||
append(getString(R.string.error_message, exception!!.message))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Handles the retry button click.
|
||||
*
|
||||
* Increments retry count and attempts to reconnect to the API.
|
||||
*/
|
||||
private fun onRetryClicked() {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
retryCount++
|
||||
val isApiReachable = attemptReconnect()
|
||||
updateUIAfterRetry(isApiReachable, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the close button click.
|
||||
*
|
||||
* Exits the application entirely.
|
||||
*/
|
||||
private fun onCloseClicked() {
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of the stack trace container and updates the link text.
|
||||
*/
|
||||
private fun onToggleStackTraceClicked() {
|
||||
isStackTraceVisible = !isStackTraceVisible
|
||||
stackTraceText.visibility = if (isStackTraceVisible) View.VISIBLE else View.GONE
|
||||
toggleStackTraceLink.text = if (isStackTraceVisible) {
|
||||
getString(R.string.hide_stack_trace)
|
||||
} else {
|
||||
getString(R.string.show_stack_trace)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to reconnect to the API immediately upon activity creation.
|
||||
*
|
||||
* This is done to obtain the initial exception details.
|
||||
*/
|
||||
private fun attemptImmediateReconnect() {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
val isApiReachable = attemptReconnect()
|
||||
if (isApiReachable) {
|
||||
navigateToMainActivity()
|
||||
} else {
|
||||
updateUIAfterRetry(isApiReachable, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeButton.setOnClickListener {
|
||||
exitProcess(0) // Close the app entirely
|
||||
}
|
||||
/**
|
||||
* Attempts to reconnect to the API using the ping method.
|
||||
* Captures exception details if the API remains unreachable.
|
||||
*
|
||||
* @return true if the API is reachable, false otherwise.
|
||||
*/
|
||||
private suspend fun attemptReconnect(): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
RetrofitClient.ping()
|
||||
true
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() != 503) throw e
|
||||
|
||||
// Attempt reconnection immediately when the activity is created
|
||||
// to obtain the error
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
exception = withContext(Dispatchers.IO) { RetrofitClient.performPingWithoutReachabilityCheck() }
|
||||
if (exception == null) {
|
||||
finish() // API reachable, dismiss the activity
|
||||
} else {
|
||||
devErrorDetailsText.text = buildString {
|
||||
append(getString(R.string.error_type, exception!!.javaClass.simpleName))
|
||||
append("\n")
|
||||
append(getString(R.string.error_message, exception!!.message))
|
||||
val errorBody = e.response()!!.errorBody()!!.string()
|
||||
exception = ExceptionSerializer.deserialize(errorBody)
|
||||
|
||||
exception!!.let {
|
||||
Log.e("API_REACHABILITY", "Exception: ${it.type}")
|
||||
Log.e("API_REACHABILITY", "Message: ${it.message}")
|
||||
Log.e("API_REACHABILITY", "Stacktrace: ${it.stacktrace.joinToString("\n")}")
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the UI after a retry attempt.
|
||||
* Displays the retry count and exception details if the API remains unreachable.
|
||||
*
|
||||
* @param isApiReachable true if the API is reachable, false otherwise.
|
||||
*/
|
||||
private fun updateUIAfterRetry(isApiReachable: Boolean, updateRetryCount: Boolean) {
|
||||
if (isApiReachable) {
|
||||
navigateToMainActivity()
|
||||
} else {
|
||||
if (updateRetryCount) retryCountText.text = getString(R.string.retry_attempts_message, retryCount)
|
||||
devErrorDetailsText.text = buildExceptionDetails()
|
||||
stackTraceContainer.visibility = View.VISIBLE
|
||||
stackTraceText.text = exception?.stacktrace?.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a detailed error message from the deserialized exception.
|
||||
*
|
||||
* @return A string containing error type and message.
|
||||
*/
|
||||
private fun buildExceptionDetails(): String {
|
||||
return buildString {
|
||||
exception!!.let {
|
||||
append(getString(R.string.error_type, it.type))
|
||||
append("\n")
|
||||
append(getString(R.string.error_message, it.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToMainActivity() {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
finish() // Close the login screen
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
isActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,13 +30,10 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private suspend fun initialize() {
|
||||
// Check API reachability
|
||||
if (!RetrofitClient.performPingWithReachabilityCheck()) {
|
||||
// The reachability interceptor will already redirect us to the
|
||||
// ApiUnreachableActivity, finish this one.
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Check API reachability. This will potentially throw HttpException with code 503
|
||||
// let it propagate up, it will be catched in our global exception handler, triggering
|
||||
// the api unreachable page.
|
||||
RetrofitClient.ping()
|
||||
|
||||
loggedIn = checkUserLogin()
|
||||
if (!loggedIn) {
|
||||
|
|
|
@ -12,7 +12,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||
import com.p_vacho.neat_calendar.api.interceptors.AuthInterceptor
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter
|
||||
import com.p_vacho.neat_calendar.api.converters.ColorConverter
|
||||
|
@ -24,17 +23,16 @@ object RetrofitClient {
|
|||
private var baseUrl: String = DEFAULT_BASE_URL
|
||||
private lateinit var appContext: Context
|
||||
|
||||
private var retrofitWithReachability: Retrofit? = null
|
||||
private var retrofitWithoutReachability: Retrofit? = null
|
||||
private var retrofitClient: Retrofit? = null
|
||||
|
||||
val authService: AuthService
|
||||
get() = retrofitWithReachability!!.create(AuthService::class.java)
|
||||
get() = retrofitClient!!.create(AuthService::class.java)
|
||||
|
||||
val generalService: GeneralService
|
||||
get() = retrofitWithReachability!!.create(GeneralService::class.java)
|
||||
get() = retrofitClient!!.create(GeneralService::class.java)
|
||||
|
||||
val eventsService: EventsService
|
||||
get() = retrofitWithReachability!!.create(EventsService::class.java)
|
||||
get() = retrofitClient!!.create(EventsService::class.java)
|
||||
|
||||
|
||||
fun initialize(context: Context) {
|
||||
|
@ -62,16 +60,13 @@ object RetrofitClient {
|
|||
}
|
||||
|
||||
private fun resetRetrofitInstances() {
|
||||
retrofitWithReachability = buildRetrofit(baseUrl, withReachability = true)
|
||||
retrofitWithoutReachability = buildRetrofit(baseUrl, withReachability = false)
|
||||
retrofitClient = buildRetrofit(baseUrl)
|
||||
}
|
||||
|
||||
private fun buildRetrofit(baseUrl: String, withReachability: Boolean): Retrofit {
|
||||
private fun buildRetrofit(baseUrl: String): Retrofit {
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
if (withReachability) {
|
||||
okHttpClient.addInterceptor(ApiReachabilityInterceptor(appContext))
|
||||
}
|
||||
okHttpClient.addInterceptor(AuthInterceptor(appContext))
|
||||
.addInterceptor(ApiReachabilityInterceptor())
|
||||
.addInterceptor(AuthInterceptor(appContext))
|
||||
|
||||
val gson = GsonBuilder()
|
||||
.registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeConverter())
|
||||
|
@ -102,16 +97,13 @@ object RetrofitClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks whether the API is reachable by making a ping request using the client
|
||||
* without the ApiReachabilityInterceptor.
|
||||
* Checks whether the API is reachable by making a ping request.
|
||||
*
|
||||
* If the API isn't reachable, this will throw HttpException with code 503.
|
||||
*/
|
||||
suspend fun performPingWithoutReachabilityCheck(): IOException? {
|
||||
val responseBody: ResponseBody
|
||||
try {
|
||||
responseBody = retrofitWithoutReachability!!.create(GeneralService::class.java).ping()
|
||||
} catch (e: IOException) {
|
||||
return e
|
||||
}
|
||||
suspend fun ping() {
|
||||
// This will potentially raise 503 HttpException
|
||||
val responseBody = retrofitClient!!.create(GeneralService::class.java).ping()
|
||||
|
||||
val responseText = responseBody.string()
|
||||
|
||||
|
@ -121,35 +113,5 @@ object RetrofitClient {
|
|||
Log.e("API", "Unexpected response from ping: $responseText")
|
||||
throw IllegalStateException("Unexpected response from ping: $responseText")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the API is reachable by making a ping request using the client
|
||||
* with the ApiReachabilityInterceptor.
|
||||
*/
|
||||
suspend fun performPingWithReachabilityCheck(): Boolean {
|
||||
// If api was unreachable, our interceptor will give us a 503 response
|
||||
val responseBody: ResponseBody
|
||||
try {
|
||||
responseBody = retrofitWithReachability!!.create(GeneralService::class.java).ping()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() == 503) {
|
||||
return false
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
val responseText = responseBody.string()
|
||||
|
||||
// This should never happen, if it does, it indicates that the API design
|
||||
// is different from what we'd expect.
|
||||
if (responseText != "pong") {
|
||||
Log.e("API", "Unexpected response from ping: $responseText")
|
||||
throw IllegalStateException("Unexpected response from ping: $responseText")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
package com.p_vacho.neat_calendar.api
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.p_vacho.neat_calendar.activities.ApiUnreachableActivity
|
||||
import com.p_vacho.neat_calendar.util.ExceptionSerializer
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
|
||||
class ApiReachabilityInterceptor(
|
||||
private val context: Context,
|
||||
) : Interceptor {
|
||||
class ApiReachabilityInterceptor() : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
@ -22,29 +21,25 @@ class ApiReachabilityInterceptor(
|
|||
return chain.proceed(originalRequest)
|
||||
} catch (e: IOException) {
|
||||
Log.w("API", "Request failed with IOException: ${e.message}", e)
|
||||
handleApiUnreachable(e)
|
||||
|
||||
// Return an error response to gracefully terminate the request
|
||||
val exceptionDetails = ExceptionSerializer.serialize(e)
|
||||
|
||||
// Return an error response to gracefully terminate the request.
|
||||
//
|
||||
// This response will make retrofit throw an HttpException, which can be caught early,
|
||||
// or, if it's propagated all the way up, it will eventually make it to our global
|
||||
// exception handler, which will show the API unreachable activity and close any other
|
||||
// activities.
|
||||
//
|
||||
// This synthetic 503 response will contain a serialized IOException, which caused this
|
||||
// failure.
|
||||
return Response.Builder()
|
||||
.request(originalRequest)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(503) // Service Unavailable
|
||||
.message("API is unreachable")
|
||||
.body(ResponseBody.create(null, "API is unreachable"))
|
||||
.body(ResponseBody.create(null, exceptionDetails))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to a blocking ApiUnreachableActivity activity, which the user can't
|
||||
* leave until the API is reached again.
|
||||
*/
|
||||
private fun handleApiUnreachable(e: IOException) {
|
||||
if (!ApiUnreachableActivity.isActive) {
|
||||
val intent = Intent(context, ApiUnreachableActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -148,6 +148,6 @@ class AuthInterceptor(
|
|||
private fun navigateToLoginActivity() {
|
||||
val intent = Intent(context, LoginActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package com.p_vacho.neat_calendar.util
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
data class SerializedException(
|
||||
val type: String,
|
||||
val message: String,
|
||||
val stacktrace: List<String>
|
||||
)
|
||||
|
||||
object ExceptionSerializer {
|
||||
|
||||
/**
|
||||
* Serializes an exception into a JSON string.
|
||||
*/
|
||||
fun serialize(e: Throwable): String {
|
||||
val stringWriter = StringWriter()
|
||||
val printWriter = PrintWriter(stringWriter)
|
||||
e.printStackTrace(printWriter)
|
||||
|
||||
val json = JSONObject()
|
||||
json.put("type", e::class.java.name)
|
||||
json.put("message", e.message ?: "No message")
|
||||
json.put("stacktrace", stringWriter.toString()) // Single string for stacktrace
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes an exception from a JSON string.
|
||||
*/
|
||||
fun deserialize(jsonString: String): SerializedException {
|
||||
val json = JSONObject(jsonString)
|
||||
val type = json.getString("type")
|
||||
val message = json.getString("message")
|
||||
val stacktrace = json.getString("stacktrace").lines() // Split stacktrace into lines
|
||||
|
||||
return SerializedException(
|
||||
type = type,
|
||||
message = message,
|
||||
stacktrace = stacktrace
|
||||
)
|
||||
}
|
||||
}
|
|
@ -106,4 +106,57 @@
|
|||
app:layout_constraintEnd_toEndOf="parent"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
|
||||
|
||||
<!-- Stack Trace Section -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stackTraceContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toBottomOf="@id/closeButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<!-- Toggle Stack Trace Link -->
|
||||
<TextView
|
||||
android:id="@+id/toggleStackTraceLink"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/show_stack_trace"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:padding="8dp"
|
||||
tools:text="Show Stack Trace" />
|
||||
|
||||
<!-- Scrollable Stack Trace -->
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:scrollbars="horizontal">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="200dp"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/stackTraceText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:scrollHorizontally="true"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:ellipsize="none"
|
||||
android:scrollbars="none"
|
||||
android:gravity="start"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:text="Sample Stack Trace Line 1\nSample Stack Trace Line 2" />
|
||||
</ScrollView>
|
||||
</HorizontalScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -26,4 +26,7 @@
|
|||
<string name="saturday_short">Sa</string>
|
||||
<string name="sunday_short">Su</string>
|
||||
<string name="event_color_indicator">Event Color Indicator</string>
|
||||
<string name="error_stacktrace">Stack Trace:</string>
|
||||
<string name="hide_stack_trace">Hide stack trace</string>
|
||||
<string name="show_stack_trace">Show stack trace</string>
|
||||
</resources>
|
Loading…
Reference in a new issue