Skip to content
33 changes: 24 additions & 9 deletions app/src/main/java/com/xpeho/xpeapp/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.xpeho.xpeapp
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
Expand Down Expand Up @@ -32,9 +31,15 @@ import okhttp3.Request
import org.json.JSONObject
import java.io.IOException
import com.xpeho.xpeho_ui_android.foundations.Colors as XpehoColors
import androidx.core.net.toUri

class MainActivity : ComponentActivity() {

companion object {
private const val TOKEN_CHECK_INTERVAL_HOURS = 8
private const val HOURS_TO_MILLISECONDS = 60 * 60 * 1000L
}

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Expand Down Expand Up @@ -65,16 +70,26 @@ class MainActivity : ComponentActivity() {
val startScreenFlow: MutableStateFlow<Screens> =
MutableStateFlow(if (connectedLastTime) Screens.Home else Screens.Login)

// Periodic check for token expiration (every 8 hours)
CoroutineScope(Dispatchers.IO).launch {
while (true) {
kotlinx.coroutines.delay(TOKEN_CHECK_INTERVAL_HOURS * HOURS_TO_MILLISECONDS)

// Check if we are connected and if the token has expired
val authState = XpeApp.appModule.authenticationManager.authState.value
if (authState is com.xpeho.xpeapp.domain.AuthState.Authenticated) {
// isAuthValid() automatically logs out if the token has expired
XpeApp.appModule.authenticationManager.isAuthValid()
}
}
}

// If the user was connected last time, try to restore the authentication state.
if (connectedLastTime) {
CoroutineScope(Dispatchers.IO).launch {
XpeApp.appModule.authenticationManager.restoreAuthStateFromStorage()
if (!XpeApp.appModule.authenticationManager.isAuthValid()) {
XpeApp.appModule.authenticationManager.logout()
withContext(Dispatchers.Main) {
startScreenFlow.value = Screens.Login
}
}
// Check validity after restoration (automatic logout if expired)
XpeApp.appModule.authenticationManager.isAuthValid()
}
}

Expand Down Expand Up @@ -145,7 +160,7 @@ class MainActivity : ComponentActivity() {
.setCancelable(false)
.setPositiveButton(getString(R.string.force_update_popup_button_label)) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("market://details?id=$packageName")
data = "market://details?id=$packageName".toUri()
setPackage("com.android.vending")
}
startActivity(intent)
Expand All @@ -167,7 +182,7 @@ class MainActivity : ComponentActivity() {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")

val jsonResponse = JSONObject(response.body?.string() ?: "")
val jsonResponse = JSONObject(response.body.string())
jsonResponse.getString("tag_name")
}
}
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/xpeho/xpeapp/data/DatastorePref.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class DatastorePref(
val CONNECT = stringPreferencesKey("isConnectedLeastOneTime")
val AUTH_DATA = stringPreferencesKey("authData")
val WAS_CONNECTED_LAST_TIME = stringPreferencesKey("wasConnectedLastTime")
val LAST_EMAIL = stringPreferencesKey("lastEmail")
}

val isConnectedLeastOneTime: Flow<Boolean> = context.dataStore.data
Expand Down Expand Up @@ -85,5 +86,19 @@ class DatastorePref(
preferences[WAS_CONNECTED_LAST_TIME]?.toBoolean() ?: false
}.first()
}

// Last email persistence

suspend fun setLastEmail(email: String) {
context.dataStore.edit { preference ->
preference[LAST_EMAIL] = email
}
}

suspend fun getLastEmail(): String? {
return context.dataStore.data.map { preferences ->
preferences[LAST_EMAIL]
}.first()
}
}

48 changes: 44 additions & 4 deletions app/src/main/java/com/xpeho/xpeapp/domain/AuthenticationManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,40 @@ class AuthenticationManager(
val firebaseService: FirebaseService
) {

companion object {
private const val TOKEN_VALIDITY_DAYS = 5
private const val DAYS_TO_MILLISECONDS = 24 * 60 * 60 * 1000L
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Unauthenticated)

val authState = _authState.asStateFlow()

fun restoreAuthStateFromStorage() = runBlocking {
datastorePref.getAuthData()?.let {
_authState.value = AuthState.Authenticated(it)
tokenProvider.set("Bearer ${it.token.token}")
datastorePref.getAuthData()?.let { authData ->
// Verify if the token has expired (5 days)
if (isTokenExpired(authData)) {
// The token has expired, perform logout
logout()
} else {
// The token is still valid, restore the authenticated state
_authState.value = AuthState.Authenticated(authData)
tokenProvider.set("Bearer ${authData.token.token}")
}
}
}

suspend fun isAuthValid(): Boolean {
return when (val authState = this.authState.value) {
is AuthState.Unauthenticated -> false
is AuthState.Authenticated -> {
// Verify if the token has expired (5 days)
if (isTokenExpired(authState.authData)) {
logout()
return false
}

// Note(loucas): Order of operations here is important,
// lazy `&&` evalutation makes this faster
firebaseService.isAuthenticated()
Expand All @@ -53,6 +71,23 @@ class AuthenticationManager(
}
}

/**
* Verify if the token has expired.
* A token is considered expired if it is older than 5 days.
* @param authData: The authentication data containing the token and its saved timestamp.
* @return True if the token has expired, false otherwise.
*/
private fun isTokenExpired(authData: AuthData): Boolean {
val currentTime = System.currentTimeMillis()
val tokenAge = currentTime - authData.tokenSavedTimestamp
val tokenValidityInMillis = TOKEN_VALIDITY_DAYS * DAYS_TO_MILLISECONDS

val ageInDays = tokenAge / DAYS_TO_MILLISECONDS
Log.d("AuthenticationManager", "Token age: $ageInDays days")

return tokenAge > tokenValidityInMillis
}

fun getAuthData(): AuthData? {
return when (val authState = this.authState.value) {
is AuthState.Authenticated -> authState.authData
Expand Down Expand Up @@ -104,6 +139,7 @@ class AuthenticationManager(
datastorePref.setAuthData(authData)
datastorePref.setIsConnectedLeastOneTime(true)
datastorePref.setWasConnectedLastTime(true)
datastorePref.setLastEmail(username)
wordpressUid?.let { datastorePref.setUserId(it) }
}

Expand All @@ -120,4 +156,8 @@ sealed interface AuthState {
data class Authenticated(val authData: AuthData) : AuthState
}

data class AuthData(val username: String, val token: WordpressToken)
data class AuthData(
val username: String,
val token: WordpressToken,
val tokenSavedTimestamp: Long = System.currentTimeMillis()
)
16 changes: 16 additions & 0 deletions app/src/main/java/com/xpeho/xpeapp/ui/Home.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.xpeho.xpeapp.XpeApp
import com.xpeho.xpeapp.domain.AuthState
import com.xpeho.xpeapp.enums.Screens

// Navigation animation duration in milliseconds.
Expand All @@ -17,6 +21,18 @@ private const val NAV_ANIM_DURATION_MILLIS = 300
fun Home(startScreen: Screens) {
val navigationController = rememberNavController()

// Observe the authentication state to handle navigation accordingly.
val authState = XpeApp.appModule.authenticationManager.authState.collectAsStateWithLifecycle()

LaunchedEffect(authState.value) {
if (authState.value is AuthState.Unauthenticated &&
navigationController.currentDestination?.route != Screens.Login.name) {
navigationController.navigate(Screens.Login.name) {
popUpTo(0) { inclusive = true }
}
}
}

NavHost(
navController = navigationController,
startDestination = startScreen.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.xpeho.xpeapp.ui.viewModel

import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.xpeho.xpeapp.XpeApp
import com.xpeho.xpeapp.data.entity.AuthentificationBody
import com.xpeho.xpeapp.data.model.AuthResult
import com.xpeho.xpeapp.domain.AuthenticationManager
Expand All @@ -16,13 +18,33 @@
) : ViewModel() {

var body: AuthentificationBody? by mutableStateOf(null)
var usernameInput: String by mutableStateOf("")
var usernameInput: String by mutableStateOf(getLastEmailSync())
var passwordInput: String by mutableStateOf("")
var usernameInError: Boolean by mutableStateOf(false)
var passwordInError: Boolean by mutableStateOf(false)

var wordpressState: WordpressUiState by mutableStateOf(WordpressUiState.EMPTY)

private fun getLastEmailSync(): String {
return try {
kotlinx.coroutines.runBlocking {
XpeApp.appModule.datastorePref.getLastEmail() ?: ""
}
} catch (e: kotlinx.coroutines.CancellationException) {
// Coroutine was cancelled, this is expected behavior
Log.d("WordpressViewModel: getLastEmailSync", "Coroutine cancelled: ${e.message}")
""
} catch (e: IllegalStateException) {
// DataStore access issue or coroutine scope issue
Log.w("WordpressViewModel: getLastEmailSync", "IllegalStateException: ${e.message}")
""
} catch (e: RuntimeException) {
// Other runtime issues (like uninitialized DataStore)
Log.e("WordpressViewModel: getLastEmailSync", "RuntimeException: ${e.message}")
""
}
}

fun onLogin() {
viewModelScope.launch {
usernameInError = false
Expand Down
Loading
Loading