feat: Handle api unreachable globally
This commit is contained in:
parent
0e5d52b66b
commit
87e9af0bb0
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
9
app/src/main/res/drawable/ic_exit.xml
Normal file
9
app/src/main/res/drawable/ic_exit.xml
Normal 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>
|
77
app/src/main/res/layout/activity_api_unreachable.xml
Normal file
77
app/src/main/res/layout/activity_api_unreachable.xml
Normal 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>
|
|
@ -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>
|
Loading…
Reference in a new issue