Skip to content
29 changes: 26 additions & 3 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 kotlin.time.Duration.Companion.hours
import androidx.core.net.toUri

class MainActivity : ComponentActivity() {

companion object {
private val TOKEN_CHECK_INTERVAL = 8.hours
}

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Expand Down Expand Up @@ -65,6 +70,24 @@ 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.inWholeMilliseconds)

// 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) {
if (!XpeApp.appModule.authenticationManager.isAuthValid()) {
XpeApp.appModule.authenticationManager.logout()
withContext(Dispatchers.Main) {
startScreenFlow.value = Screens.Login
}
}
}
}
}

// If the user was connected last time, try to restore the authentication state.
if (connectedLastTime) {
CoroutineScope(Dispatchers.IO).launch {
Expand Down Expand Up @@ -145,7 +168,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 +190,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()
}
}

54 changes: 45 additions & 9 deletions app/src/main/java/com/xpeho/xpeapp/domain/AuthenticationManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds

/**
* Singleton responsible for keeping track of the authentication state,
Expand All @@ -29,22 +31,39 @@ class AuthenticationManager(
val firebaseService: FirebaseService
) {

companion object {
private val TOKEN_VALIDITY_PERIOD = 5.days
}

@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,11 +72,23 @@ class AuthenticationManager(
}
}

fun getAuthData(): AuthData? {
return when (val authState = this.authState.value) {
is AuthState.Authenticated -> authState.authData
else -> null
}
/**
* 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 tokenAge = (System.currentTimeMillis() - authData.tokenSavedTimestamp).milliseconds

Log.d("AuthenticationManager", "Token age: ${tokenAge.inWholeDays} days")

return tokenAge > TOKEN_VALIDITY_PERIOD
}

fun getAuthData(): AuthData? = when (val authState = this.authState.value) {
is AuthState.Authenticated -> authState.authData
is AuthState.Unauthenticated -> null
}

suspend fun login(username: String, password: String): AuthResult<WordpressToken> = coroutineScope {
Expand Down Expand Up @@ -104,6 +135,7 @@ class AuthenticationManager(
datastorePref.setAuthData(authData)
datastorePref.setIsConnectedLeastOneTime(true)
datastorePref.setWasConnectedLastTime(true)
datastorePref.setLastEmail(username)
wordpressUid?.let { datastorePref.setUserId(it) }
}

Expand All @@ -120,4 +152,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,28 +1,60 @@
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.datastore.core.IOException
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
import com.xpeho.xpeapp.ui.uiState.WordpressUiState
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlin.coroutines.cancellation.CancellationException

class WordpressViewModel(
private var authManager: AuthenticationManager
) : 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 runCatching {
kotlinx.coroutines.runBlocking {
XpeApp.appModule.datastorePref.getLastEmail() ?: ""
}
}.getOrElse { throwable ->
when (throwable) {
is CancellationException -> {
Log.d("WordpressViewModel", "Coroutine cancelled: ${throwable.message}")
}
is IOException -> {
Log.e("WordpressViewModel", "DataStore IO error: ${throwable.message}")
}
is SecurityException -> {
Log.e("WordpressViewModel", "Permission denied: ${throwable.message}")
}
is SerializationException -> {
Log.e("WordpressViewModel", "Serialization error: ${throwable.message}")
}
else -> {
Log.e("WordpressViewModel", "Unexpected error: ${throwable.message}")
}
}
""
}
}

fun onLogin() {
viewModelScope.launch {
usernameInError = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class AuthenticationManagerTest {
// Mock android.util.Log methods
mockkStatic(Log::class)
every { Log.e(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
}
}

Expand Down Expand Up @@ -144,6 +145,7 @@ class AuthenticationManagerTest {
coEvery { datastorePref.setAuthData(any()) } just runs
coEvery { datastorePref.setIsConnectedLeastOneTime(true) } just runs
coEvery { datastorePref.setWasConnectedLastTime(true) } just runs
coEvery { datastorePref.setLastEmail(username) } just runs
coEvery { datastorePref.setUserId("userId") } just runs
coEvery { tokenProvider.set(any()) } just runs

Expand Down Expand Up @@ -267,4 +269,82 @@ class AuthenticationManagerTest {
}
}

class TokenExpirationTests : BaseTest() {
@Test
fun `isAuthValid returns false when token is expired`() = runBlocking {
// Create an expired token (6 days old for current config)
val expiredTimestamp = System.currentTimeMillis() - (6 * 24 * 60 * 60 * 1000L) // 6 days ago
val authData = AuthData(
"username",
WordpressToken("token", "user_email", "user_nicename", "user_display_name"),
expiredTimestamp
)

coEvery { datastorePref.getAuthData() } returns authData
coEvery { tokenProvider.set(any()) } just runs
coEvery { firebaseService.signOut() } just runs
coEvery { datastorePref.clearAuthData() } just runs
coEvery { datastorePref.setWasConnectedLastTime(false) } just runs

authManager.restoreAuthStateFromStorage()
val result = authManager.isAuthValid()

assertFalse(result)
// Verify that logout was called due to expired token
assertTrue(authManager.authState.value is AuthState.Unauthenticated)
}

@Test
fun `restoreAuthStateFromStorage logs out when token is expired`() = runBlocking {
// Create an expired token (6 days old for current config)
val expiredTimestamp = System.currentTimeMillis() - (6 * 24 * 60 * 60 * 1000L) // 6 days ago
val authData = AuthData(
"username",
WordpressToken("token", "user_email", "user_nicename", "user_display_name"),
expiredTimestamp
)

coEvery { datastorePref.getAuthData() } returns authData
coEvery { firebaseService.signOut() } just runs
coEvery { datastorePref.clearAuthData() } just runs
coEvery { datastorePref.setWasConnectedLastTime(false) } just runs

authManager.restoreAuthStateFromStorage()

// Verify that logout was called due to expired token
assertTrue(authManager.authState.value is AuthState.Unauthenticated)
coVerify { firebaseService.signOut() }
}

@Test
fun `restoreAuthStateFromStorage succeeds when token is valid`() = runBlocking {
// Create a valid token (current time)
val validAuthData = AuthData(
"username",
WordpressToken("token", "user_email", "user_nicename", "user_display_name"),
System.currentTimeMillis()
)

coEvery { datastorePref.getAuthData() } returns validAuthData
coEvery { tokenProvider.set(any()) } just runs

authManager.restoreAuthStateFromStorage()

// Verify that the user remains authenticated
assertTrue(authManager.authState.value is AuthState.Authenticated)
val authState = authManager.authState.value as AuthState.Authenticated
assertTrue(authState.authData.username == "username")
}

@Test
fun `restoreAuthStateFromStorage handles null auth data gracefully`() = runBlocking {
coEvery { datastorePref.getAuthData() } returns null

authManager.restoreAuthStateFromStorage()

// Verify that the user remains unauthenticated
assertTrue(authManager.authState.value is AuthState.Unauthenticated)
}
}

}
Loading