feat: Handle api unreachable globally

This commit is contained in:
Peter Vacho 2024-12-23 22:57:45 +01:00
parent 0e5d52b66b
commit 87e9af0bb0
Signed by: school
GPG key ID: 8CFC3837052871B4
9 changed files with 334 additions and 81 deletions

View file

@ -16,6 +16,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.NeatCalendar"
tools:targetApi="31">
<activity
android:name=".ApiUnreachableActivity"
android:exported="false" />
<activity
android:name=".RegisterActivity"
android:exported="false" />

View file

@ -0,0 +1,98 @@
package com.p_vacho.neat_calendar
import android.content.Intent
import android.os.Bundle
import android.text.Html
import android.widget.Button
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.p_vacho.neat_calendar.api.RetrofitClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.system.exitProcess
class ApiUnreachableActivity : AppCompatActivity() {
private var retryCount = 0
private var exception: IOException? = null
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
}
override fun onCreate(savedInstanceState: Bundle?) {
isActive = true
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_api_unreachable)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
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)
retryButton.setOnClickListener {
CoroutineScope(Dispatchers.Main).launch {
retryCount++
exception = withContext(Dispatchers.IO) { RetrofitClient.performPingWithoutReachabilityCheck() }
if (exception == null) {
navigateToMainActivity()
} else {
retryCountText.text = getString(R.string.retry_attempts_message, retryCount)
devErrorDetailsText.text = buildString {
append(getString(R.string.error_type, exception!!.javaClass.simpleName))
append("\n")
append(getString(R.string.error_message, exception!!.message))
}
}
}
}
closeButton.setOnClickListener {
exitProcess(0) // Close the app entirely
}
// 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))
}
}
}
}
private fun navigateToMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
finish() // Close the login screen
}
override fun onDestroy() {
super.onDestroy()
isActive = false
}
}

View file

@ -8,16 +8,11 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.auth.AuthRepository
import com.p_vacho.neat_calendar.auth.TokenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private var apiReachable = false
private var loggedIn = false
private val authRepository by lazy { (application as MyApplication).authRepository }
@ -25,17 +20,18 @@ class MainActivity : AppCompatActivity() {
// Attach the splash screen to the activity before initialization begins
val splashScreen = installSplashScreen()
// Keep the splash screen visible until initialization is complete
splashScreen.setKeepOnScreenCondition { !apiReachable || !loggedIn }
// Keep the splash screen visible until user login status is determined
splashScreen.setKeepOnScreenCondition { !loggedIn }
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
// Perform initialization (api reachability check & login)
// Perform initialization (login check)
CoroutineScope(Dispatchers.Main).launch {
initialize()
}
// Handle window insets for proper layout adjustment
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
@ -45,19 +41,18 @@ class MainActivity : AppCompatActivity() {
}
private suspend fun initialize() {
if (!apiReachable) {
apiReachable = checkApiReachability()
// Check API reachability
if (!RetrofitClient.performPingWithReachabilityCheck()) {
// The reachability interceptor will already redirect us to the
// ApiUnreachableActivity, finish this one.
finish()
return;
}
if (!apiReachable) showApiErrorDialog()
loggedIn = checkUserLogin()
if (!loggedIn) navigateToLoginActivity()
}
private suspend fun checkApiReachability(): Boolean {
return RetrofitClient.isApiReachable()
if (!loggedIn) {
navigateToLoginActivity()
}
}
private suspend fun checkUserLogin(): Boolean {
@ -66,7 +61,7 @@ class MainActivity : AppCompatActivity() {
// it's better to just get a new one so it doesn't expire as quickly on us)
return authRepository.refreshAccessToken()
}
return false;
return false
}
private fun navigateToLoginActivity() {
@ -75,39 +70,4 @@ class MainActivity : AppCompatActivity() {
startActivity(intent)
finish() // Close MainActivity
}
// Show an error dialog if the API is unreachable
private fun showApiErrorDialog() {
var retryCount = 0;
val dialog = androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Error")
.setMessage("The API is unreachable.\nPlease check your network connection.")
.setPositiveButton("Retry", null)
.setNegativeButton("Close") { _, _ -> finish() } // Close the app
.setCancelable(false)
.create()
// Show dialog and handle "Retry" button dynamically
dialog.setOnShowListener {
val retryButton = dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE)
retryButton.setOnClickListener {
CoroutineScope(Dispatchers.Main).launch {
retryCount++
apiReachable = checkApiReachability()
if (apiReachable) {
dialog.dismiss() // Close dialog and proceed
initialize() // re-run initialization
} else {
dialog.setMessage(
"Retry failed. Attempts: $retryCount\n" +
"Please check you network and try again."
)
}
}
}
}
dialog.show()
}
}

View file

@ -1,63 +1,113 @@
package com.p_vacho.neat_calendar.api
import android.content.Context
import android.util.Log
import com.p_vacho.neat_calendar.api.services.AuthService
import com.p_vacho.neat_calendar.api.services.GeneralService
import com.p_vacho.neat_calendar.auth.TokenManager
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.util.Log
import com.p_vacho.neat_calendar.api.interceptors.AuthInterceptor
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.io.IOException
object RetrofitClient {
private const val BASE_URL = "http://10.0.2.2:8000"
private lateinit var retrofit: Retrofit
private lateinit var retrofitWithReachability: Retrofit
private lateinit var retrofitWithoutReachability: Retrofit
val authService: AuthService by lazy {
retrofit.create(AuthService::class.java)
retrofitWithReachability.create(AuthService::class.java)
}
val generalService: GeneralService by lazy {
retrofit.create(GeneralService::class.java)
retrofitWithReachability.create(GeneralService::class.java)
}
/**
* Initializes the RetrofitClient with context and token manager
*/
fun initialize(context: Context) {
val apiReachabilityInterceptor = ApiReachabilityInterceptor(context)
val authInterceptor = AuthInterceptor(context)
// TODO: Add another interceptor that makes sure the api remains reachable
// (or modify AuthInterceptor & probably also rename it)
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor) // Adds the AuthInterceptor
// OkHttpClient with ApiReachabilityInterceptor
val okHttpClientWithReachability = OkHttpClient.Builder()
.addInterceptor(apiReachabilityInterceptor)
.addInterceptor(authInterceptor)
.build()
retrofit = Retrofit.Builder()
// OkHttpClient without ApiReachabilityInterceptor
val okHttpClientWithoutReachability = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
retrofitWithReachability = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient) // Set custom OkHttpClient
.client(okHttpClientWithReachability)
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofitWithoutReachability = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClientWithoutReachability)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
suspend fun isApiReachable(): Boolean {
return withContext(Dispatchers.IO) {
try {
val responseBody: ResponseBody = RetrofitClient.generalService.ping()
val responseText = responseBody.string()
if (responseText != "pong") {
Log.w("API", "Unexpected response from ping: $responseText")
}
responseText == "pong"
} catch (e: Exception) {
Log.e("API", "Error while checking API reachability", e)
false
}
/**
* Checks whether the API is reachable by making a ping request using the client
* without the ApiReachabilityInterceptor.
*/
suspend fun performPingWithoutReachabilityCheck(): IOException? {
val responseBody: ResponseBody
try {
responseBody = retrofitWithoutReachability.create(GeneralService::class.java).ping()
} catch (e: IOException) {
return 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 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

@ -0,0 +1,50 @@
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.ApiUnreachableActivity
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody
import java.io.IOException
class ApiReachabilityInterceptor(
private val context: Context,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
try {
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
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"))
.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

@ -1,4 +1,4 @@
package com.p_vacho.neat_calendar.api
package com.p_vacho.neat_calendar.api.interceptors
import android.content.Context
import android.content.Intent

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M200,840q-33,0 -56.5,-23.5T120,760v-160h80v160h560v-560L200,200v160h-80v-160q0,-33 23.5,-56.5T200,120h560q33,0 56.5,23.5T840,200v560q0,33 -23.5,56.5T760,840L200,840ZM420,680 L364,622 466,520L120,520v-80h346L364,338l56,-58 200,200 -200,200Z"
android:fillColor="#e8eaed"/>
</vector>

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ApiUnreachableActivity">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/messageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/apiUnreachable"
android:textAppearance="?android:attr/textAppearanceLarge"
android:gravity="center"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/devErrorDetailsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text=""
android:textAppearance="?android:attr/textAppearanceSmall"
android:gravity="center"
android:padding="8dp"
app:layout_constraintTop_toBottomOf="@id/messageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/retryButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginHorizontal="48dp"
android:text="@string/retry"
app:layout_constraintTop_toBottomOf="@id/devErrorDetailsText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/Widget.MaterialComponents.Button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/closeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginHorizontal="48dp"
android:text="@string/close"
app:icon="@drawable/ic_exit"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:layout_constraintTop_toBottomOf="@id/retryButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/retryCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text=""
android:textAppearance="?android:attr/textAppearanceLarge"
android:gravity="center"
android:padding="16dp"
app:layout_constraintTop_toBottomOf="@id/closeButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -5,4 +5,10 @@
<string name="login">Login</string>
<string name="register">Register</string>
<string name="email">Email</string>
<string name="apiUnreachable">The API is unreachable. Please check your network connection.</string>
<string name="close">Close</string>
<string name="retry">Retry</string>
<string name="retry_attempts_message">Retry failed. Attempts: %1$d</string>
<string name="error_type">Type: %1$s</string>
<string name="error_message">Message: %1$s</string>
</resources>