feat: Properly handle api unreachable scenarios

This commit is contained in:
Peter Vacho 2024-12-30 20:41:48 +01:00
parent fb3d7785ba
commit b9d64cc1cf
Signed by: school
GPG key ID: 8CFC3837052871B4
10 changed files with 323 additions and 124 deletions

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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>

View file

@ -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>