feat: Add api interaction logic

This commit is contained in:
Peter Vacho 2024-12-22 23:18:42 +01:00
parent 8d5242d780
commit ec81d4ee12
Signed by: school
GPG key ID: 8CFC3837052871B4
10 changed files with 300 additions and 7 deletions

View file

@ -47,4 +47,7 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
}

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View file

@ -6,25 +6,36 @@ import androidx.appcompat.app.AppCompatActivity
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private var apiReachable = false // Variable to track API reachability
override fun onCreate(savedInstanceState: Bundle?) {
// Attach the splash screen to the activity before initialization begins
// (before super.onCreate) to make it show up during the activity init.
val splashScreen = installSplashScreen()
// Keep the splash screen visible until initialization is complete
splashScreen.setKeepOnScreenCondition {
// TODO: Once implemented, make sure keep the splashscreen on until
// all of the necessary data are obtained from the API, for now,
// no initialization delay is needed.
false
}
splashScreen.setKeepOnScreenCondition { !apiReachable }
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
// Check API availability in a coroutine
CoroutineScope(Dispatchers.Main).launch {
apiReachable = RetrofitClient.isApiReachable()
if (!apiReachable) {
// Handle unreachable API, e.g., show an error message or retry
showApiErrorDialog()
}
}
// Handle window insets for proper layout adjustment
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
@ -32,4 +43,46 @@ class MainActivity : AppCompatActivity() {
insets
}
}
// 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.\n" +
"Please check your network connection."
)
.setPositiveButton("Retry", null) // Listener set later for custom handling
.setNegativeButton("Close") { _, _ ->
// Close the app or navigate to an error screen
finish()
}
.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++
val reachable = RetrofitClient.isApiReachable()
if (reachable) {
apiReachable = true
dialog.dismiss() // Close dialog and proceed
} else {
// Update dialog message for failed retry
dialog.setMessage(
"Retry failed. Attempts: $retryCount\n" +
"Please check you network and try again."
)
}
}
}
}
dialog.show()
}
}

View file

@ -0,0 +1,45 @@
package com.p_vacho.neat_calendar.api
import com.p_vacho.neat_calendar.api.services.AuthService
import com.p_vacho.neat_calendar.api.services.GeneralService
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.util.Log
import okhttp3.ResponseBody
object RetrofitClient {
private const val BASE_URL = "https://90f2-213-160-184-230.ngrok-free.app"
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val authService: AuthService by lazy {
retrofit.create(AuthService::class.java)
}
val generalService: GeneralService by lazy {
retrofit.create(GeneralService::class.java)
}
suspend fun isApiReachable(): Boolean {
return withContext(Dispatchers.IO) {
try {
val responseBody: ResponseBody = RetrofitClient.generalService.ping()
val responseText = responseBody.string() // Extract the string content from ResponseBody
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
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.p_vacho.neat_calendar.api.models
data class LoginRequest(
val username: String,
val password: String,
)
data class LoginResponse(
val access_token: String,
val expires_in: Int,
val refresh_token_expires_in: Int,
val token_type: String,
val user_id: String,
val refresh_token: String,
)
data class RefreshResponse(
val access_token: String,
val expires_in: Int,
val token_type: String,
val user_id: String,
)
data class SessionResponse(
val id: String,
val session_type: String, // "refresh" or "access"
val parent_session_id: String?,
val expires_at: String, // ISO 8601
val created_at: String, // ISO 8601
val revoked: Boolean,
)

View file

@ -0,0 +1,22 @@
package com.p_vacho.neat_calendar.api.services
import com.p_vacho.neat_calendar.api.models.*
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
interface AuthService {
@POST("auth/login")
suspend fun login(@Body loginRequest: LoginRequest): LoginResponse
@POST("auth/refresh")
suspend fun refreshToken(@Header("Authorization") refreshToken: String): RefreshResponse
@POST("auth/logout")
suspend fun logout(@Header("Authorization") refreshToken: String): Response<Unit>
@GET("session")
suspend fun getSession(@Header("Authorization") token: String): SessionResponse
}

View file

@ -0,0 +1,9 @@
package com.p_vacho.neat_calendar.api.services
import okhttp3.ResponseBody
import retrofit2.http.GET
interface GeneralService {
@GET("ping")
suspend fun ping(): ResponseBody // pong
}

View file

@ -0,0 +1,74 @@
package com.p_vacho.neat_calendar.auth
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.LoginRequest
import com.p_vacho.neat_calendar.api.models.SessionResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AuthRepository(private val tokenManager: TokenManager) {
// Login user and store tokens
suspend fun login(username: String, password: String): Boolean {
return withContext(Dispatchers.IO) {
try {
val response = RetrofitClient.authService.login(LoginRequest(username, password))
tokenManager.storeAccessToken(response.access_token, response.expires_in)
tokenManager.storeRefreshToken(response.refresh_token, response.refresh_token_expires_in)
true
} catch (e: Exception) {
false
}
}
}
// Validate token by fetching session info
suspend fun validateToken(): SessionResponse? {
return withContext(Dispatchers.IO) {
val token = tokenManager.accessToken ?: return@withContext null
try {
RetrofitClient.authService.getSession("Bearer $token")
} catch (e: Exception) {
null
}
}
}
// Refresh access token if valid refresh token exists
suspend fun refreshAccessToken(): Boolean {
return withContext(Dispatchers.IO) {
if (tokenManager.isRefreshTokenExpired()) {
// Refresh token is expired; log out user
logout()
return@withContext false
}
try {
val refreshToken = tokenManager.refreshToken ?: return@withContext false
val response = RetrofitClient.authService.refreshToken("Bearer $refreshToken")
tokenManager.storeAccessToken(response.access_token, response.expires_in)
true
} catch (e: Exception) {
false
}
}
}
// Logout user and clear tokens
suspend fun logout(): Boolean {
return withContext(Dispatchers.IO) {
try {
val refreshToken = tokenManager.refreshToken ?: return@withContext false
val response = RetrofitClient.authService.logout("Bearer $refreshToken")
if (response.isSuccessful) {
tokenManager.clearTokens()
true
} else {
false
}
} catch (e: Exception) {
false
}
}
}
}

View file

@ -0,0 +1,50 @@
package com.p_vacho.neat_calendar.auth
import android.content.Context
import android.content.SharedPreferences
class TokenManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("auth_prefs", Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString("access_token", null)
private set(value) = prefs.edit().putString("access_token", value).apply()
var refreshToken: String?
get() = prefs.getString("refresh_token", null)
private set(value) = prefs.edit().putString("refresh_token", value).apply()
var accessTokenExpiresAt: Long
get() = prefs.getLong("access_token_expires_at", 0L)
private set(value) = prefs.edit().putLong("access_token_expires_at", value).apply()
var refreshTokenExpiresAt: Long
get() = prefs.getLong("refresh_token_expires_at", 0L)
private set(value) = prefs.edit().putLong("refresh_token_expires_at", value).apply()
fun isAccessTokenExpired(): Boolean {
return accessTokenExpiresAt * 1000 <= System.currentTimeMillis()
}
fun isRefreshTokenExpired(): Boolean {
return refreshTokenExpiresAt * 1000 <= System.currentTimeMillis()
}
fun storeAccessToken(accessToken: String, expiresIn: Int) {
this.accessToken = accessToken
this.accessTokenExpiresAt = System.currentTimeMillis() / 1000 + expiresIn
}
fun storeRefreshToken(refreshToken: String, expiresIn: Int) {
this.refreshToken = refreshToken
this.refreshTokenExpiresAt = System.currentTimeMillis() / 1000 + expiresIn
}
fun clearTokens() {
accessToken = null
refreshToken = null
accessTokenExpiresAt = 0L
refreshTokenExpiresAt = 0L
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
</PreferenceScreen>