diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b99fdcc..03d93a3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -27,7 +27,8 @@
@@ -38,6 +39,8 @@
+
+
@@ -49,6 +52,8 @@
+
+
diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt
index 01ae740..3a58463 100644
--- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt
+++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt
@@ -12,6 +12,7 @@ import com.interlinedlist.android.core.datastore.SessionStore
import com.interlinedlist.android.core.datastore.ThemeMode
import com.interlinedlist.android.core.datastore.ThemeSettingsStore
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
+import com.interlinedlist.android.feature.auth.nav.AuthRoutes
import com.interlinedlist.android.navigation.InterlinedListNavHost
import com.interlinedlist.android.navigation.NotificationLaunch
import dagger.hilt.android.AndroidEntryPoint
@@ -35,6 +36,10 @@ class MainActivity : ComponentActivity() {
// A tapped system notification launches us with deep-link extras; resolve the
// pending in-app route so the signed-in shell can navigate straight to it.
val notificationRoute = NotificationLaunch.fromIntent(intent)?.route
+ // A tapped email-change link (confirm or undo) launches us with the token in
+ // the VIEW intent's data. Both endpoints behind it are unauthenticated, so the
+ // route resolves regardless of whether a session exists.
+ val emailChangeRoute = AuthRoutes.routeForEmailChangeLink(intent?.dataString)
enableEdgeToEdge()
setContent {
val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle()
@@ -47,6 +52,7 @@ class MainActivity : ComponentActivity() {
InterlinedListNavHost(
startLoggedIn = startLoggedIn,
notificationRoute = notificationRoute,
+ emailChangeRoute = emailChangeRoute,
)
}
}
diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
index 3d9827b..80e8529 100644
--- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
+++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt
@@ -195,6 +195,7 @@ private enum class HomeTab(val route: String, val label: String, val icon: Image
fun InterlinedListNavHost(
startLoggedIn: Boolean,
notificationRoute: String? = null,
+ emailChangeRoute: String? = null,
) {
val navController = rememberNavController()
NavHost(
@@ -230,6 +231,13 @@ fun InterlinedListNavHost(
)
}
}
+
+ // A tapped email-change link resolves to a route in the auth graph, which is
+ // registered above whether or not the app started signed in — so the confirm and
+ // undo screens are reachable straight from the email either way.
+ LaunchedEffect(Unit) {
+ emailChangeRoute?.let { route -> runCatching { navController.navigate(route) } }
+ }
}
/**
diff --git a/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt
new file mode 100644
index 0000000..ed5691e
--- /dev/null
+++ b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt
@@ -0,0 +1,96 @@
+package com.interlinedlist.android.feature.auth.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
+import com.interlinedlist.android.feature.auth.nav.EmailChangeAction
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The confirm/undo result screen. The undo half is a security affordance, so the
+ * copy has to say plainly that the previous address was restored — these assertions
+ * guard that wording against being softened into a generic "done".
+ */
+@RunWith(AndroidJUnit4::class)
+class EmailChangeScreenTest {
+
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ private fun setScreen(state: EmailChangeUiState, onDone: () -> Unit = {}) {
+ composeRule.setContent {
+ InterlinedListTheme { EmailChangeScreen(state = state, onDone = onDone) }
+ }
+ }
+
+ @Test
+ fun verifySuccess_saysTheAccountNowUsesTheNewAddress() {
+ setScreen(EmailChangeUiState(EmailChangeAction.VERIFY, EmailChangeStatus.DONE))
+
+ composeRule.onNodeWithTag(EmailChangeTestTags.HEADING).assertIsDisplayed()
+ composeRule.onNodeWithTag(EmailChangeTestTags.BODY).assertIsDisplayed()
+ assertThat(composeRule.textOf(EmailChangeTestTags.HEADING)).contains("updated")
+ }
+
+ @Test
+ fun undoSuccess_spellsOutWhatWasRestoredAndWhatToDoNext() {
+ setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.DONE))
+
+ assertThat(composeRule.textOf(EmailChangeTestTags.HEADING)).contains("undone")
+ val body = composeRule.textOf(EmailChangeTestTags.BODY)
+ assertThat(body).contains("restored")
+ assertThat(body).contains("change your password")
+ }
+
+ @Test
+ fun failure_showsTheServersOwnMessage() {
+ setScreen(
+ EmailChangeUiState(
+ action = EmailChangeAction.VERIFY,
+ status = EmailChangeStatus.FAILED,
+ message = "That email is already in use",
+ ),
+ )
+
+ assertThat(composeRule.textOf(EmailChangeTestTags.BODY)).isEqualTo("That email is already in use")
+ }
+
+ @Test
+ fun invalidLink_saysNothingChangedAndOffersNoRetry() {
+ setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.INVALID_LINK))
+
+ assertThat(composeRule.textOf(EmailChangeTestTags.BODY)).contains("nothing was changed")
+ composeRule.onNodeWithTag(EmailChangeTestTags.PROGRESS).assertDoesNotExist()
+ }
+
+ @Test
+ fun working_showsProgressAndNoDoneButton() {
+ setScreen(EmailChangeUiState(EmailChangeAction.VERIFY, EmailChangeStatus.WORKING))
+
+ composeRule.onNodeWithTag(EmailChangeTestTags.PROGRESS).assertIsDisplayed()
+ composeRule.onNodeWithTag(EmailChangeTestTags.DONE).assertDoesNotExist()
+ }
+
+ @Test
+ fun done_invokesTheCallback() {
+ var done = 0
+ setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.DONE)) { done++ }
+
+ composeRule.onNodeWithTag(EmailChangeTestTags.DONE).performClick()
+
+ assertThat(done).isEqualTo(1)
+ }
+}
+
+/** Reads the text semantics of the node tagged [tag]. */
+private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.textOf(tag: String): String =
+ onNodeWithTag(tag)
+ .fetchSemanticsNode()
+ .config[androidx.compose.ui.semantics.SemanticsProperties.Text]
+ .joinToString(separator = "") { it.text }
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt
index 6555799..7a2c1e2 100644
--- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt
@@ -40,6 +40,19 @@ interface AuthRepository {
/** Resends the verification email to the signed-in (unverified) user. */
suspend fun resendVerificationEmail(): ApiResult
+ /**
+ * Completes a pending email change with the token from the link mailed to the
+ * new address (`POST /api/auth/verify-email-change`). Needs no session.
+ */
+ suspend fun verifyEmailChange(token: String): ApiResult
+
+ /**
+ * Reverts an email change with the token from the link mailed to the previous
+ * address (`POST /api/auth/undo-email-change`). Needs no session, by design:
+ * the person reaching for it may no longer be able to sign in.
+ */
+ suspend fun undoEmailChange(token: String): ApiResult
+
/** Clears the persisted session and cached user. */
suspend fun logout()
}
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt
index 036a4fe..c38c3fa 100644
--- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt
@@ -14,6 +14,8 @@ import com.interlinedlist.android.feature.auth.data.remote.AuthApi
import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest
+import com.interlinedlist.android.feature.auth.data.remote.dto.UndoEmailChangeRequest
+import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailChangeRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -84,6 +86,16 @@ class DefaultAuthRepository @Inject constructor(
safeApiCall(json) { authApi.sendVerificationEmail() }
}
+ override suspend fun verifyEmailChange(token: String): ApiResult =
+ withContext(dispatchers.io) {
+ safeApiCall(json) { authApi.verifyEmailChange(VerifyEmailChangeRequest(token)) }
+ }
+
+ override suspend fun undoEmailChange(token: String): ApiResult =
+ withContext(dispatchers.io) {
+ safeApiCall(json) { authApi.undoEmailChange(UndoEmailChangeRequest(token)) }
+ }
+
override suspend fun logout() = withContext(dispatchers.io) {
sessionStore.clear()
userDao.clear()
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt
index a064be9..52d9054 100644
--- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt
@@ -3,6 +3,8 @@ package com.interlinedlist.android.feature.auth.data.remote
import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest
+import com.interlinedlist.android.feature.auth.data.remote.dto.UndoEmailChangeRequest
+import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailChangeRequest
import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest
import retrofit2.http.Body
import retrofit2.http.POST
@@ -38,4 +40,19 @@ interface AuthApi {
/** Resends the verification email to the signed-in (unverified) user. */
@POST("api/auth/send-verification-email")
suspend fun sendVerificationEmail()
+
+ /**
+ * Completes a pending email change with the token from the link mailed to the
+ * new address. Unauthenticated, so it also works from a signed-out app.
+ */
+ @POST("api/auth/verify-email-change")
+ suspend fun verifyEmailChange(@Body body: VerifyEmailChangeRequest)
+
+ /**
+ * Reverts an email change with the token from the link mailed to the previous
+ * address. Unauthenticated by design — the account owner may have already lost
+ * access when they reach for it.
+ */
+ @POST("api/auth/undo-email-change")
+ suspend fun undoEmailChange(@Body body: UndoEmailChangeRequest)
}
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt
index 1d94363..6e2dece 100644
--- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt
@@ -40,3 +40,23 @@ data class ResetPasswordRequest(
data class VerifyEmailRequest(
val token: String,
)
+
+/**
+ * `POST /api/auth/verify-email-change` — confirms a pending email change with the
+ * token from the message sent to the *new* address. Unauthenticated
+ * (`x-auth-type: none` in the OpenAPI spec), so the tap works from a signed-out app.
+ */
+@Serializable
+data class VerifyEmailChangeRequest(
+ val token: String,
+)
+
+/**
+ * `POST /api/auth/undo-email-change` — reverts an email change using the token from
+ * the message sent to the *previous* address. Also unauthenticated by design: the
+ * whole point is that somebody who has lost access to the account can still undo it.
+ */
+@Serializable
+data class UndoEmailChangeRequest(
+ val token: String,
+)
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt
index 40e3413..8494a5d 100644
--- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt
@@ -11,6 +11,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
+import com.interlinedlist.android.feature.auth.ui.EMAIL_CHANGE_ACTION_ARG
+import com.interlinedlist.android.feature.auth.ui.EmailChangeRoute
import com.interlinedlist.android.feature.auth.ui.ForgotPasswordRoute
import com.interlinedlist.android.feature.auth.ui.LoginRoute
import com.interlinedlist.android.feature.auth.ui.RegisterRoute
@@ -41,18 +43,50 @@ object AuthRoutes {
* `` maps `https://interlinedlist.com/reset-password` and
* `/verify-email` onto these so `NavController.handleDeepLink` lands directly
* on the matching screen with its `token` populated.
+ *
+ * Built lazily: `NavDeepLink` parses its pattern with `android.net.Uri`, and the
+ * plain route helpers on this object are covered by JVM unit tests that must not
+ * drag the Android framework in just by touching the object.
*/
- val RESET_DEEP_LINKS: List = listOf(
- navDeepLink { uriPattern = "https://interlinedlist.com/reset-password?$TOKEN_ARG={$TOKEN_ARG}" },
- navDeepLink { uriPattern = "interlinedlist://reset-password?$TOKEN_ARG={$TOKEN_ARG}" },
- )
- val VERIFY_DEEP_LINKS: List = listOf(
- navDeepLink { uriPattern = "https://interlinedlist.com/verify-email?$TOKEN_ARG={$TOKEN_ARG}" },
- navDeepLink { uriPattern = "interlinedlist://verify-email?$TOKEN_ARG={$TOKEN_ARG}" },
- )
+ val RESET_DEEP_LINKS: List by lazy {
+ listOf(
+ navDeepLink { uriPattern = "https://interlinedlist.com/reset-password?$TOKEN_ARG={$TOKEN_ARG}" },
+ navDeepLink { uriPattern = "interlinedlist://reset-password?$TOKEN_ARG={$TOKEN_ARG}" },
+ )
+ }
+ val VERIFY_DEEP_LINKS: List by lazy {
+ listOf(
+ navDeepLink { uriPattern = "https://interlinedlist.com/verify-email?$TOKEN_ARG={$TOKEN_ARG}" },
+ navDeepLink { uriPattern = "interlinedlist://verify-email?$TOKEN_ARG={$TOKEN_ARG}" },
+ )
+ }
fun reset(token: String) = "auth/reset?$TOKEN_ARG=$token"
fun verify(token: String) = "auth/verify?$TOKEN_ARG=$token"
+
+ /**
+ * Confirm-or-undo destination for the two email-change links. Both halves share
+ * one screen and differ only by the `action` argument, so the emailed
+ * `/verify-email-change` and `/undo-email-change` links map onto the same route.
+ */
+ const val EMAIL_CHANGE =
+ "auth/email-change?$EMAIL_CHANGE_ACTION_ARG={$EMAIL_CHANGE_ACTION_ARG}&$TOKEN_ARG={$TOKEN_ARG}"
+
+ fun emailChange(action: EmailChangeAction, token: String) =
+ "auth/email-change?$EMAIL_CHANGE_ACTION_ARG=${action.name}&$TOKEN_ARG=$token"
+
+ /**
+ * Maps a tapped email-change link onto an in-app route, or returns null when the
+ * URI is not one of those links (or carries no token).
+ *
+ * The app resolves the launch intent through here — the same way a tapped
+ * notification goes through `NotificationLaunch` — rather than relying on implicit
+ * `navDeepLink` matching, so this security-sensitive entry point is exercised by
+ * plain unit tests. Both endpoints behind it are unauthenticated, so the route
+ * resolves whether or not the app has a session.
+ */
+ fun routeForEmailChangeLink(uri: String?): String? =
+ EmailChangeLink.parse(uri)?.let { emailChange(it.action, it.token) }
}
/**
@@ -104,6 +138,28 @@ fun NavGraphBuilder.authGraph(
)
}
+ composable(
+ route = AuthRoutes.EMAIL_CHANGE,
+ arguments = listOf(
+ navArgument(EMAIL_CHANGE_ACTION_ARG) {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument(AuthRoutes.TOKEN_ARG) {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ ),
+ ) {
+ EmailChangeRoute(
+ // Reached from an email while signed in *or* signed out: go back to
+ // whatever was underneath, falling back to Login when nothing is.
+ onDone = { if (!navController.popBackStack()) navController.popToLogin() },
+ )
+ }
+
composable(
route = AuthRoutes.VERIFY,
arguments = listOf(
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt
new file mode 100644
index 0000000..27205f5
--- /dev/null
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt
@@ -0,0 +1,114 @@
+package com.interlinedlist.android.feature.auth.nav
+
+import java.net.URI
+import java.net.URLDecoder
+
+/** Which half of the email-change flow an emailed link completes. */
+enum class EmailChangeAction {
+ /** Confirm the new address — the link mailed to the *new* inbox. */
+ VERIFY,
+
+ /** Revert the change — the link mailed to the *previous* inbox. */
+ UNDO,
+}
+
+/**
+ * A parsed email-change deep link: which [action] the tapped link performs and the
+ * one-time [token] it carries.
+ *
+ * Kept as plain JVM code (no `android.net.Uri`) so the parsing rules are covered by
+ * fast unit tests — this is the entry point for a *security* action reached from an
+ * email, so silently mis-parsing it is not acceptable.
+ */
+data class EmailChangeLink(
+ val action: EmailChangeAction,
+ val token: String,
+) {
+ companion object {
+
+ /** Query parameter carrying the one-time token on both links. */
+ const val TOKEN_PARAM = "token"
+
+ /** Web path for the "confirm the new address" link. */
+ const val VERIFY_PATH = "verify-email-change"
+
+ /** Web path for the "this wasn't me — undo it" link. */
+ const val UNDO_PATH = "undo-email-change"
+
+ /** Host of the web links; also matched with a `www.` prefix. */
+ const val WEB_HOST = "interlinedlist.com"
+
+ /** Custom scheme the app registers for the same two links. */
+ const val APP_SCHEME = "interlinedlist"
+
+ private val WEB_SCHEMES = setOf("https", "http")
+
+ /**
+ * Parses [uri] into an [EmailChangeLink], or returns null when it is not one
+ * of the two email-change links or carries no usable token.
+ *
+ * Recognised shapes (scheme/host case-insensitive, trailing slash and extra
+ * query parameters tolerated):
+ * - `https://interlinedlist.com/verify-email-change?token=…`
+ * - `https://interlinedlist.com/undo-email-change?token=…`
+ * - `interlinedlist://verify-email-change?token=…`
+ * - `interlinedlist://undo-email-change?token=…`
+ *
+ * Anything malformed — a missing or blank token, a foreign host, an unrelated
+ * path, or a string that is not a URI at all — yields null rather than an
+ * exception or a half-populated link.
+ */
+ fun parse(uri: String?): EmailChangeLink? {
+ val trimmed = uri?.trim().orEmpty()
+ if (trimmed.isEmpty()) return null
+ val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return null
+
+ val action = parsed.actionOrNull() ?: return null
+ val token = parsed.rawQuery.queryParam(TOKEN_PARAM)?.takeIf { it.isNotBlank() } ?: return null
+ return EmailChangeLink(action, token)
+ }
+
+ /**
+ * The action this URI targets, or null when it is not an email-change link.
+ * Web links carry the action in the path; custom-scheme links carry it in the
+ * authority (`interlinedlist://verify-email-change`).
+ */
+ private fun URI.actionOrNull(): EmailChangeAction? {
+ val scheme = scheme?.lowercase() ?: return null
+ val target = when {
+ scheme in WEB_SCHEMES -> {
+ val host = host?.lowercase()?.removePrefix("www.") ?: return null
+ if (host != WEB_HOST) return null
+ path.orEmpty().trim('/')
+ }
+ scheme == APP_SCHEME -> {
+ // `interlinedlist://verify-email-change?token=…` — the action sits in
+ // the authority; tolerate it appearing as a leading path segment too.
+ val authority = host ?: authority
+ (authority.orEmpty() + path.orEmpty()).trim('/')
+ }
+ else -> return null
+ }
+ return when (target.lowercase()) {
+ VERIFY_PATH -> EmailChangeAction.VERIFY
+ UNDO_PATH -> EmailChangeAction.UNDO
+ else -> null
+ }
+ }
+
+ /** Reads a single percent-decoded query parameter out of a raw query string. */
+ private fun String?.queryParam(name: String): String? = this
+ ?.split('&')
+ ?.firstNotNullOfOrNull { pair ->
+ val separator = pair.indexOf('=')
+ if (separator <= 0) return@firstNotNullOfOrNull null
+ val key = pair.substring(0, separator).decodeOrNull()
+ if (!key.equals(name, ignoreCase = true)) return@firstNotNullOfOrNull null
+ pair.substring(separator + 1).decodeOrNull()
+ }
+
+ /** Percent-decodes a query component, falling back to the raw text. */
+ private fun String.decodeOrNull(): String? =
+ runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this)
+ }
+}
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt
new file mode 100644
index 0000000..6cc2d5e
--- /dev/null
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt
@@ -0,0 +1,192 @@
+package com.interlinedlist.android.feature.auth.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark
+import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
+import com.interlinedlist.android.feature.auth.nav.EmailChangeAction
+
+/** Stable test tags for the email-change confirm / undo screen. */
+object EmailChangeTestTags {
+ const val HEADING = "emailChangeHeading"
+ const val BODY = "emailChangeBody"
+ const val PROGRESS = "emailChangeProgress"
+ const val DONE = "emailChangeDone"
+}
+
+/** Hilt-wired entry point; action + token arrive as deep-link nav arguments. */
+@Composable
+fun EmailChangeRoute(
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: EmailChangeViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ EmailChangeScreen(state = state, onDone = onDone, modifier = modifier)
+}
+
+/**
+ * Stateless result screen for both emailed email-change links.
+ *
+ * The copy is deliberately explicit about *which* address won: an undo is a security
+ * affordance, so the screen spells out that the previous address has been restored
+ * and tells a user who did not start the change what to do next.
+ */
+@Composable
+fun EmailChangeScreen(
+ state: EmailChangeUiState,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(modifier = modifier.fillMaxSize()) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp, vertical = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ InterlinedListWordmark()
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ text = headingFor(state),
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.testTag(EmailChangeTestTags.HEADING),
+ )
+
+ if (state.status == EmailChangeStatus.WORKING) {
+ Spacer(Modifier.height(16.dp))
+ CircularProgressIndicator(
+ modifier = Modifier.size(28.dp).testTag(EmailChangeTestTags.PROGRESS),
+ )
+ }
+
+ val failed = state.status == EmailChangeStatus.FAILED
+ val body = if (failed) state.message ?: bodyFor(state) else bodyFor(state)
+ if (body != null) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (failed || state.status == EmailChangeStatus.INVALID_LINK) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().testTag(EmailChangeTestTags.BODY),
+ )
+ }
+
+ if (state.status != EmailChangeStatus.WORKING) {
+ Spacer(Modifier.height(24.dp))
+ Button(
+ onClick = onDone,
+ modifier = Modifier.fillMaxWidth().testTag(EmailChangeTestTags.DONE),
+ ) {
+ Text("Done")
+ }
+ }
+ }
+ }
+}
+
+/** Heading for the current action + status combination. */
+private fun headingFor(state: EmailChangeUiState): String = when (state.action) {
+ EmailChangeAction.VERIFY -> when (state.status) {
+ EmailChangeStatus.WORKING -> "Confirming your new email…"
+ EmailChangeStatus.DONE -> "Email address updated"
+ EmailChangeStatus.FAILED -> "Couldn't confirm the new email"
+ EmailChangeStatus.INVALID_LINK -> "This confirmation link is incomplete"
+ }
+ EmailChangeAction.UNDO -> when (state.status) {
+ EmailChangeStatus.WORKING -> "Undoing the email change…"
+ EmailChangeStatus.DONE -> "Email change undone"
+ EmailChangeStatus.FAILED -> "Couldn't undo the email change"
+ EmailChangeStatus.INVALID_LINK -> "This undo link is incomplete"
+ }
+}
+
+/** Supporting copy; the failure case prefers the server's own message. */
+private fun bodyFor(state: EmailChangeUiState): String? = when (state.action) {
+ EmailChangeAction.VERIFY -> when (state.status) {
+ EmailChangeStatus.WORKING -> null
+ EmailChangeStatus.DONE ->
+ "Your account now uses your new email address. Sign in with it from now on."
+ EmailChangeStatus.FAILED ->
+ "The link may have expired or already been used. Request the change again " +
+ "from Account settings."
+ EmailChangeStatus.INVALID_LINK ->
+ "It's missing its security token, so nothing was changed. Open the link " +
+ "straight from the email instead of copying it by hand."
+ }
+ EmailChangeAction.UNDO -> when (state.status) {
+ EmailChangeStatus.WORKING ->
+ "This restores the email address your account had before the change."
+ EmailChangeStatus.DONE ->
+ "Your account email has been restored to the previous address and the " +
+ "change was cancelled. If you did not request that change, someone " +
+ "else may have access to your account — change your password now."
+ EmailChangeStatus.FAILED ->
+ "The link may have expired or already been used. Contact support if you " +
+ "did not request the email change."
+ EmailChangeStatus.INVALID_LINK ->
+ "It's missing its security token, so nothing was changed. Open the link " +
+ "straight from the email instead of copying it by hand."
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmailChangeVerifiedPreview() {
+ InterlinedListTheme {
+ EmailChangeScreen(
+ state = EmailChangeUiState(
+ action = EmailChangeAction.VERIFY,
+ status = EmailChangeStatus.DONE,
+ ),
+ onDone = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmailChangeUndonePreview() {
+ InterlinedListTheme {
+ EmailChangeScreen(
+ state = EmailChangeUiState(
+ action = EmailChangeAction.UNDO,
+ status = EmailChangeStatus.DONE,
+ ),
+ onDone = {},
+ )
+ }
+}
diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt
new file mode 100644
index 0000000..bf07d3f
--- /dev/null
+++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt
@@ -0,0 +1,112 @@
+package com.interlinedlist.android.feature.auth.ui
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.interlinedlist.android.core.common.result.ApiResult
+import com.interlinedlist.android.feature.auth.data.AuthRepository
+import com.interlinedlist.android.feature.auth.nav.EmailChangeAction
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/** Nav argument key for which half of the flow the tapped link completes. */
+const val EMAIL_CHANGE_ACTION_ARG = "action"
+
+/** Nav argument key for the one-time token carried by the emailed link. */
+const val EMAIL_CHANGE_TOKEN_ARG = "token"
+
+/** Where the email-change confirmation stands. */
+enum class EmailChangeStatus {
+ /** The link carried no usable token — nothing was sent to the server. */
+ INVALID_LINK,
+
+ /** The token is being submitted. */
+ WORKING,
+
+ /** The server accepted the token. */
+ DONE,
+
+ /** The server rejected the token (expired, already used, address taken…). */
+ FAILED,
+}
+
+/** UI state for the email-change confirm / undo screen. */
+data class EmailChangeUiState(
+ val action: EmailChangeAction = EmailChangeAction.VERIFY,
+ val status: EmailChangeStatus = EmailChangeStatus.WORKING,
+ /** Server-provided (or mapped) detail shown under the heading on failure. */
+ val message: String? = null,
+)
+
+/**
+ * Drives the screen both emailed email-change links land on.
+ *
+ * Both `POST /api/auth/verify-email-change` and `POST /api/auth/undo-email-change`
+ * are unauthenticated (`x-auth-type: none`, empty `security` in the OpenAPI spec), so
+ * the token is submitted straight away without requiring a session — the whole point
+ * of the undo link is that whoever reaches for it may no longer be able to sign in.
+ *
+ * A link with no token never reaches the network: it reports [EmailChangeStatus.INVALID_LINK]
+ * so a truncated or hand-edited URL can't be mistaken for a success.
+ */
+@HiltViewModel
+class EmailChangeViewModel(
+ private val authRepository: AuthRepository,
+ action: EmailChangeAction,
+ token: String?,
+) : ViewModel() {
+
+ /** Hilt entry point: reads the action + token from the deep-link nav arguments. */
+ @Inject
+ constructor(
+ authRepository: AuthRepository,
+ savedStateHandle: SavedStateHandle,
+ ) : this(
+ authRepository = authRepository,
+ action = actionFrom(savedStateHandle.get(EMAIL_CHANGE_ACTION_ARG)),
+ token = savedStateHandle.get(EMAIL_CHANGE_TOKEN_ARG),
+ )
+
+ private val _uiState = MutableStateFlow(EmailChangeUiState(action = action))
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ val trimmed = token?.trim()
+ if (trimmed.isNullOrEmpty()) {
+ _uiState.update { it.copy(status = EmailChangeStatus.INVALID_LINK) }
+ } else {
+ submit(action, trimmed)
+ }
+ }
+
+ private fun submit(action: EmailChangeAction, token: String) {
+ _uiState.update { it.copy(status = EmailChangeStatus.WORKING, message = null) }
+ viewModelScope.launch {
+ val result = when (action) {
+ EmailChangeAction.VERIFY -> authRepository.verifyEmailChange(token)
+ EmailChangeAction.UNDO -> authRepository.undoEmailChange(token)
+ }
+ when (result) {
+ is ApiResult.Success -> _uiState.update { it.copy(status = EmailChangeStatus.DONE) }
+ is ApiResult.Failure -> _uiState.update {
+ it.copy(
+ status = EmailChangeStatus.FAILED,
+ message = result.error.toUserMessage(),
+ )
+ }
+ }
+ }
+ }
+
+ private companion object {
+ /** Unknown/missing nav values default to the non-destructive half of the flow. */
+ fun actionFrom(raw: String?): EmailChangeAction =
+ EmailChangeAction.entries.firstOrNull { it.name.equals(raw, ignoreCase = true) }
+ ?: EmailChangeAction.VERIFY
+ }
+}
diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt
index accfb65..8c46ab2 100644
--- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt
+++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt
@@ -200,4 +200,66 @@ class DefaultAuthRepositoryTest {
assertThat(result).isInstanceOf(ApiResult.Success::class.java)
assertThat(server.takeRequest().path).contains("api/auth/send-verification-email")
}
+
+ // ---- email change: verify / undo ---------------------------------------
+
+ @Test
+ fun `verifyEmailChange posts the token to the verify-email-change endpoint`() = runTest(dispatcher) {
+ enqueue(201)
+
+ val result = repository().verifyEmailChange("change-tok")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ val request = server.takeRequest()
+ assertThat(request.path).contains("api/auth/verify-email-change")
+ assertThat(request.body.readUtf8()).isEqualTo("{\"token\":\"change-tok\"}")
+ }
+
+ @Test
+ fun `verifyEmailChange surfaces the server message on a conflict`() = runTest(dispatcher) {
+ enqueue(409, """{ "error": "That email is already in use", "code": "conflict" }""")
+
+ val result = repository().verifyEmailChange("taken")
+
+ assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
+ val error = (result as ApiResult.Failure).error
+ assertThat(error).isInstanceOf(AppError.Conflict::class.java)
+ assertThat(error.message).isEqualTo("That email is already in use")
+ }
+
+ @Test
+ fun `undoEmailChange posts the token to the undo-email-change endpoint`() = runTest(dispatcher) {
+ enqueue(201)
+
+ val result = repository().undoEmailChange("undo-tok")
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ val request = server.takeRequest()
+ assertThat(request.path).contains("api/auth/undo-email-change")
+ assertThat(request.body.readUtf8()).isEqualTo("{\"token\":\"undo-tok\"}")
+ }
+
+ @Test
+ fun `undoEmailChange works with no session at all`() = runTest(dispatcher) {
+ // The emailed undo link has to work for someone who cannot sign in any more,
+ // so the repository must not gate the call on a stored token.
+ session.clear()
+ enqueue(201)
+
+ val result = repository().undoEmailChange("undo-tok")
+
+ assertThat(session.isLoggedIn).isFalse()
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ assertThat(server.takeRequest().path).contains("api/auth/undo-email-change")
+ }
+
+ @Test
+ fun `undoEmailChange surfaces an expired-link message`() = runTest(dispatcher) {
+ enqueue(400, """{ "error": "Undo link has expired", "code": "bad_request" }""")
+
+ val result = repository().undoEmailChange("stale")
+
+ assertThat(result).isInstanceOf(ApiResult.Failure::class.java)
+ assertThat((result as ApiResult.Failure).error.message).isEqualTo("Undo link has expired")
+ }
}
diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt
new file mode 100644
index 0000000..f80286d
--- /dev/null
+++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt
@@ -0,0 +1,122 @@
+package com.interlinedlist.android.feature.auth.nav
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Parsing rules for the two emailed email-change links.
+ *
+ * These are the entry point for a security action reached from an email, so the
+ * "must not match" cases matter as much as the happy paths: a malformed or foreign
+ * link has to come back as null rather than as a half-populated link that the screen
+ * would then submit.
+ */
+class EmailChangeLinkTest {
+
+ // ---- the four link shapes ---------------------------------------------
+
+ @Test
+ fun `parses the https verify-email-change link`() {
+ val link = EmailChangeLink.parse("https://interlinedlist.com/verify-email-change?token=abc123")
+
+ assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123"))
+ }
+
+ @Test
+ fun `parses the https undo-email-change link`() {
+ val link = EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=abc123")
+
+ assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.UNDO, "abc123"))
+ }
+
+ @Test
+ fun `parses the custom-scheme verify link`() {
+ val link = EmailChangeLink.parse("interlinedlist://verify-email-change?token=abc123")
+
+ assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123"))
+ }
+
+ @Test
+ fun `parses the custom-scheme undo link`() {
+ val link = EmailChangeLink.parse("interlinedlist://undo-email-change?token=abc123")
+
+ assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.UNDO, "abc123"))
+ }
+
+ // ---- tolerated variations ---------------------------------------------
+
+ @Test
+ fun `tolerates a www host, a trailing slash, other query params and a fragment`() {
+ val link = EmailChangeLink.parse(
+ "https://WWW.InterlinedList.com/verify-email-change/?utm_source=email&token=abc123#top",
+ )
+
+ assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123"))
+ }
+
+ @Test
+ fun `percent-decodes the token`() {
+ val link = EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=a%2Bb%3Dc")
+
+ assertThat(link?.token).isEqualTo("a+b=c")
+ }
+
+ @Test
+ fun `surrounding whitespace is ignored`() {
+ val link = EmailChangeLink.parse(" https://interlinedlist.com/verify-email-change?token=abc123 ")
+
+ assertThat(link?.action).isEqualTo(EmailChangeAction.VERIFY)
+ }
+
+ // ---- malformed / foreign links must not match -------------------------
+
+ @Test
+ fun `a link with no token does not parse`() {
+ assertThat(EmailChangeLink.parse("https://interlinedlist.com/verify-email-change")).isNull()
+ }
+
+ @Test
+ fun `a link with a blank token does not parse`() {
+ assertThat(EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=")).isNull()
+ assertThat(EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=%20")).isNull()
+ }
+
+ @Test
+ fun `a look-alike host does not parse`() {
+ assertThat(
+ EmailChangeLink.parse("https://interlinedlist.com.evil.example/verify-email-change?token=abc"),
+ ).isNull()
+ }
+
+ @Test
+ fun `the plain verify-email link is not mistaken for an email change`() {
+ assertThat(EmailChangeLink.parse("https://interlinedlist.com/verify-email?token=abc")).isNull()
+ }
+
+ @Test
+ fun `garbage input returns null instead of throwing`() {
+ assertThat(EmailChangeLink.parse("not a uri at all")).isNull()
+ assertThat(EmailChangeLink.parse("")).isNull()
+ assertThat(EmailChangeLink.parse(null)).isNull()
+ assertThat(EmailChangeLink.parse("verify-email-change?token=abc")).isNull()
+ }
+
+ // ---- route mapping -----------------------------------------------------
+
+ @Test
+ fun `routeForEmailChangeLink maps each link onto its in-app route`() {
+ assertThat(
+ AuthRoutes.routeForEmailChangeLink("https://interlinedlist.com/verify-email-change?token=abc123"),
+ ).isEqualTo("auth/email-change?action=VERIFY&token=abc123")
+
+ assertThat(
+ AuthRoutes.routeForEmailChangeLink("interlinedlist://undo-email-change?token=abc123"),
+ ).isEqualTo("auth/email-change?action=UNDO&token=abc123")
+ }
+
+ @Test
+ fun `routeForEmailChangeLink ignores links it does not own`() {
+ assertThat(AuthRoutes.routeForEmailChangeLink("https://interlinedlist.com/lists/shared/xyz")).isNull()
+ assertThat(AuthRoutes.routeForEmailChangeLink(null)).isNull()
+ }
+}
diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt
new file mode 100644
index 0000000..eb031be
--- /dev/null
+++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt
@@ -0,0 +1,100 @@
+package com.interlinedlist.android.feature.auth.ui
+
+import com.google.common.truth.Truth.assertThat
+import com.interlinedlist.android.core.common.result.ApiResult
+import com.interlinedlist.android.core.common.result.AppError
+import com.interlinedlist.android.feature.auth.nav.EmailChangeAction
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class EmailChangeViewModelTest {
+
+ private val dispatcher = StandardTestDispatcher()
+
+ @Before fun setUp() = Dispatchers.setMain(dispatcher)
+ @After fun tearDown() = Dispatchers.resetMain()
+
+ @Test
+ fun `a verify link confirms the change with the emailed token`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository(verifyEmailChangeResult = ApiResult.Success(Unit))
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = "verify-tok")
+ advanceUntilIdle()
+
+ assertThat(repo.lastVerifyEmailChangeToken).isEqualTo("verify-tok")
+ assertThat(repo.lastUndoEmailChangeToken).isNull()
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.DONE)
+ assertThat(vm.uiState.value.action).isEqualTo(EmailChangeAction.VERIFY)
+ }
+
+ @Test
+ fun `an undo link reverts the change with the emailed token`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository(undoEmailChangeResult = ApiResult.Success(Unit))
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = "undo-tok")
+ advanceUntilIdle()
+
+ assertThat(repo.lastUndoEmailChangeToken).isEqualTo("undo-tok")
+ assertThat(repo.lastVerifyEmailChangeToken).isNull()
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.DONE)
+ assertThat(vm.uiState.value.action).isEqualTo(EmailChangeAction.UNDO)
+ }
+
+ @Test
+ fun `a rejected verify token surfaces the server's own message`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository(
+ verifyEmailChangeResult = ApiResult.Failure(AppError.Conflict("That email is already in use")),
+ )
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = "stale")
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.FAILED)
+ assertThat(vm.uiState.value.message).isEqualTo("That email is already in use")
+ }
+
+ @Test
+ fun `a rejected undo token surfaces the server's own message`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository(
+ undoEmailChangeResult = ApiResult.Failure(AppError.Unknown("Undo link has expired")),
+ )
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = "stale")
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.FAILED)
+ assertThat(vm.uiState.value.message).isEqualTo("Undo link has expired")
+ }
+
+ @Test
+ fun `a link with no token never reaches the server and never reports success`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository()
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = null)
+ advanceUntilIdle()
+
+ assertThat(repo.lastUndoEmailChangeToken).isNull()
+ assertThat(repo.lastVerifyEmailChangeToken).isNull()
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.INVALID_LINK)
+ }
+
+ @Test
+ fun `a blank token is treated as an invalid link`() = runTest(dispatcher) {
+ val repo = FakeAuthRepository()
+
+ val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = " ")
+ advanceUntilIdle()
+
+ assertThat(repo.lastVerifyEmailChangeToken).isNull()
+ assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.INVALID_LINK)
+ }
+}
diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt
index 649a62a..1cdf351 100644
--- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt
+++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt
@@ -24,6 +24,8 @@ class FakeAuthRepository(
var resetResult: ApiResult = ApiResult.Success(Unit),
var verifyResult: ApiResult = ApiResult.Success(Unit),
var resendResult: ApiResult = ApiResult.Success(Unit),
+ var verifyEmailChangeResult: ApiResult = ApiResult.Success(Unit),
+ var undoEmailChangeResult: ApiResult = ApiResult.Success(Unit),
) : AuthRepository {
var loginCount = 0
@@ -33,6 +35,8 @@ class FakeAuthRepository(
var lastReset: ResetArgs? = null
var lastVerifyToken: String? = null
var resendCount = 0
+ var lastVerifyEmailChangeToken: String? = null
+ var lastUndoEmailChangeToken: String? = null
data class RegisterArgs(
val email: String,
@@ -81,5 +85,15 @@ class FakeAuthRepository(
return resendResult
}
+ override suspend fun verifyEmailChange(token: String): ApiResult {
+ lastVerifyEmailChangeToken = token
+ return verifyEmailChangeResult
+ }
+
+ override suspend fun undoEmailChange(token: String): ApiResult {
+ lastUndoEmailChangeToken = token
+ return undoEmailChangeResult
+ }
+
override suspend fun logout() = Unit
}
diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt
index deba0d9..209e849 100644
--- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt
+++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt
@@ -3,10 +3,14 @@ package com.interlinedlist.android.feature.profile.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.profile.domain.LoginSession
+import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsScreen
+import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsTestTags
+import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsUiState
import com.interlinedlist.android.feature.profile.ui.account.SessionsScreen
import com.interlinedlist.android.feature.profile.ui.account.SessionsTestTags
import com.interlinedlist.android.feature.profile.ui.account.SessionsUiState
@@ -67,4 +71,50 @@ class AccountScreensTest {
composeRule.onNodeWithTag(SessionsTestTags.CONFIRM_REVOKE).performClick()
assert(revoked == "s2")
}
+
+ // ---- pending email change ---------------------------------------------
+
+ @Test
+ fun accountSettings_pendingEmailChangeShowsAddressAndResends() {
+ var resent = 0
+ composeRule.setContent {
+ InterlinedListTheme {
+ AccountSettingsScreen(
+ state = AccountSettingsUiState(
+ username = "adron",
+ pendingEmail = "new@example.com",
+ ),
+ onChangeEmail = {},
+ onAcknowledgeEmailChange = {},
+ onResendEmailChange = { resent++ },
+ onDeleteAccount = {},
+ onBack = {},
+ )
+ }
+ }
+
+ composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_EMAIL).assertIsDisplayed()
+ composeRule.onNodeWithText("new@example.com").assertIsDisplayed()
+
+ composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_RESEND).performClick()
+ assert(resent == 1)
+ }
+
+ @Test
+ fun accountSettings_noPendingEmailChangeHidesTheBanner() {
+ composeRule.setContent {
+ InterlinedListTheme {
+ AccountSettingsScreen(
+ state = AccountSettingsUiState(username = "adron", pendingEmail = null),
+ onChangeEmail = {},
+ onAcknowledgeEmailChange = {},
+ onResendEmailChange = {},
+ onDeleteAccount = {},
+ onBack = {},
+ )
+ }
+ }
+
+ composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_EMAIL).assertDoesNotExist()
+ }
}
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt
index ac30066..7b3e882 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt
@@ -298,6 +298,13 @@ class DefaultProfileRepository @Inject constructor(
safeApiCall(json) { api.requestEmailChange(ChangeEmailRequest(newEmail)) }
}
+ override suspend fun getPendingEmailChange(): ApiResult =
+ withContext(dispatchers.io) {
+ safeApiCall(json) {
+ api.getCurrentUser().userOrSelf?.pendingEmail?.takeIf { it.isNotBlank() }
+ }
+ }
+
override suspend fun deleteAccount(username: String, email: String): ApiResult =
withContext(dispatchers.io) {
safeApiCall(json) { api.deleteAccount(DeleteAccountRequest(username = username, email = email)) }
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt
index e328136..1525aee 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt
@@ -144,6 +144,14 @@ interface ProfileRepository {
/** Requests an email change to [newEmail] via `POST /api/user/change-email/request`. */
suspend fun requestEmailChange(newEmail: String): ApiResult
+ /**
+ * The address an email change is currently waiting on, read from `pendingEmail`
+ * on `GET /api/user`, or null when no change is in flight. Deliberately not
+ * cached: it is the kind of state that must never be shown stale, and it clears
+ * the moment the change is confirmed (or undone) from the emailed link.
+ */
+ suspend fun getPendingEmailChange(): ApiResult
+
/**
* Deletes the current user's account via `POST /api/user/delete`, confirming with
* the account's [username] and [email]. On success the caller signs the user out.
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt
index 0365fa6..2374884 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt
@@ -17,6 +17,7 @@ data class ProfileResponse(
val avatar: String? = null,
val bio: String? = null,
val customerStatus: String? = null,
+ val pendingEmail: String? = null,
) {
/** The user payload, whether wrapped under `user` or inlined at the top level. */
val userOrSelf: ProfileUserDto?
@@ -29,6 +30,7 @@ data class ProfileResponse(
avatar = avatar,
bio = bio,
customerStatus = customerStatus,
+ pendingEmail = pendingEmail,
)
}
}
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt
index 5efb7f2..acc130d 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt
@@ -17,6 +17,12 @@ data class ProfileUserDto(
val avatar: String? = null,
val bio: String? = null,
val customerStatus: String? = null,
+ /**
+ * The address a requested email change is waiting on, or null when no change is
+ * in flight. Present on `GET /api/user` for the signed-in user only (confirmed
+ * live); other users' profiles omit it.
+ */
+ val pendingEmail: String? = null,
// --- Preference fields (present on `GET /api/user` for the signed-in user
// only; other users' public profiles omit them, hence all-nullable). ---
val theme: String? = null,
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt
index 74946bd..7c38ecb 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt
@@ -15,6 +15,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -48,6 +50,9 @@ object AccountSettingsTestTags {
const val EMAIL_FIELD = "accountSettingsEmailField"
const val CHANGE_EMAIL = "accountSettingsChangeEmail"
const val EMAIL_REQUESTED = "accountSettingsEmailRequested"
+ const val PENDING_EMAIL = "accountSettingsPendingEmail"
+ const val PENDING_RESEND = "accountSettingsPendingResend"
+ const val PENDING_RESENT = "accountSettingsPendingResent"
const val ERROR = "accountSettingsError"
const val DELETE_ACCOUNT = "accountSettingsDeleteAccount"
const val DELETE_DIALOG = "accountSettingsDeleteDialog"
@@ -77,6 +82,7 @@ fun AccountSettingsRoute(
state = state,
onChangeEmail = viewModel::requestEmailChange,
onAcknowledgeEmailChange = viewModel::acknowledgeEmailChange,
+ onResendEmailChange = viewModel::resendEmailChange,
onDeleteAccount = viewModel::deleteAccount,
onBack = onBack,
modifier = modifier,
@@ -105,6 +111,7 @@ fun AccountSettingsScreen(
state: AccountSettingsUiState,
onChangeEmail: (String) -> Unit,
onAcknowledgeEmailChange: () -> Unit,
+ onResendEmailChange: () -> Unit,
onDeleteAccount: (String) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -140,6 +147,16 @@ fun AccountSettingsScreen(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ if (state.pendingEmail != null) {
+ Spacer(Modifier.height(12.dp))
+ PendingEmailChangeCard(
+ pendingEmail = state.pendingEmail,
+ isResending = state.isResendingEmailChange,
+ wasResent = state.emailChangeResent,
+ onResend = onResendEmailChange,
+ )
+ }
+
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = newEmail,
@@ -227,6 +244,69 @@ fun AccountSettingsScreen(
}
}
+/**
+ * Shows the address an email change is waiting on, with the two things the API
+ * actually supports from here: re-sending the confirmation email, and the plain
+ * statement of how to stop the change (the undo link in the message sent to the
+ * current address — `POST /api/auth/undo-email-change`). The server exposes no
+ * endpoint that cancels a pending change directly, so no button pretends to.
+ */
+@Composable
+private fun PendingEmailChangeCard(
+ pendingEmail: String,
+ isResending: Boolean,
+ wasResent: Boolean,
+ onResend: () -> Unit,
+) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(AccountSettingsTestTags.PENDING_EMAIL),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Waiting for confirmation", style = MaterialTheme.typography.titleSmall)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = pendingEmail,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "Your account keeps its current email until you open the link we " +
+ "sent to this address. To stop the change, use the “undo” link in the " +
+ "email sent to your current address.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedButton(
+ onClick = onResend,
+ enabled = !isResending,
+ modifier = Modifier.testTag(AccountSettingsTestTags.PENDING_RESEND),
+ ) {
+ if (isResending) {
+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
+ } else {
+ Text("Resend confirmation email")
+ }
+ }
+ if (wasResent) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "Sent again — check that inbox.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.testTag(AccountSettingsTestTags.PENDING_RESENT),
+ )
+ }
+ }
+ }
+}
+
/**
* The type-to-confirm delete guard: the user must re-type their exact username and enter
* their email before the destructive confirm button enables.
@@ -301,9 +381,13 @@ private fun DeleteAccountDialog(
private fun AccountSettingsScreenPreview() {
InterlinedListTheme {
AccountSettingsScreen(
- state = AccountSettingsUiState(username = "adron"),
+ state = AccountSettingsUiState(
+ username = "adron",
+ pendingEmail = "new@example.com",
+ ),
onChangeEmail = {},
onAcknowledgeEmailChange = {},
+ onResendEmailChange = {},
onDeleteAccount = {},
onBack = {},
)
diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt
index 8f81d9d..7b7f008 100644
--- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt
+++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt
@@ -20,7 +20,9 @@ import javax.inject.Inject
* UI state for the Account settings screen (change email + delete account).
*
* [username] is seeded from the current user's cache and is the value the
- * type-to-confirm delete guard checks against.
+ * type-to-confirm delete guard checks against. [pendingEmail] mirrors the server's
+ * `pendingEmail` field: non-null while an email change is awaiting confirmation from
+ * the link mailed to that address, and null again once it is confirmed or undone.
*/
data class AccountSettingsUiState(
val username: String = "",
@@ -28,6 +30,11 @@ data class AccountSettingsUiState(
val isDeletingAccount: Boolean = false,
// A one-shot confirmation to show after a successful email-change request.
val emailChangeRequested: Boolean = false,
+ /** The address an email change is waiting on, or null when none is in flight. */
+ val pendingEmail: String? = null,
+ val isResendingEmailChange: Boolean = false,
+ /** A one-shot confirmation to show after the verification email is re-sent. */
+ val emailChangeResent: Boolean = false,
val errorMessage: String? = null,
)
@@ -43,6 +50,10 @@ sealed interface AccountSettingsEffect {
* `POST /api/user/delete`. On a successful delete it emits [AccountSettingsEffect.SignedOut]
* so the app can clear the session and navigate away (the profile module does not own
* session state).
+ *
+ * The pending-change banner is re-read from the server on every entry rather than
+ * cached, so it disappears as soon as the change is confirmed (or undone) from the
+ * emailed link — which happens outside this screen, and possibly on another device.
*/
@HiltViewModel
class AccountSettingsViewModel @Inject constructor(
@@ -57,6 +68,22 @@ class AccountSettingsViewModel @Inject constructor(
init {
seedFromCache()
+ refreshPendingEmailChange()
+ }
+
+ /**
+ * Re-reads `pendingEmail` from `GET /api/user`. Called on entry and after a
+ * request/resend so the banner reflects the server, not a local guess.
+ */
+ fun refreshPendingEmailChange() {
+ viewModelScope.launch {
+ when (val result = repository.getPendingEmailChange()) {
+ is ApiResult.Success -> _uiState.update { it.copy(pendingEmail = result.data) }
+ // A failed read is not worth an error banner on a settings screen:
+ // leave whatever is on screen alone and try again next entry.
+ is ApiResult.Failure -> Unit
+ }
+ }
}
/** Seeds [AccountSettingsUiState.username] from the cached current user. */
@@ -77,8 +104,15 @@ class AccountSettingsViewModel @Inject constructor(
_uiState.update { it.copy(isChangingEmail = true, emailChangeRequested = false, errorMessage = null) }
viewModelScope.launch {
when (val result = repository.requestEmailChange(email)) {
- is ApiResult.Success -> _uiState.update {
- it.copy(isChangingEmail = false, emailChangeRequested = true)
+ is ApiResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isChangingEmail = false,
+ emailChangeRequested = true,
+ pendingEmail = email,
+ )
+ }
+ refreshPendingEmailChange()
}
is ApiResult.Failure -> _uiState.update {
it.copy(isChangingEmail = false, errorMessage = result.error.toUserMessage())
@@ -87,6 +121,31 @@ class AccountSettingsViewModel @Inject constructor(
}
}
+ /**
+ * Re-sends the confirmation email for the change already in flight, by re-issuing
+ * the same `POST /api/user/change-email/request` for the pending address.
+ */
+ fun resendEmailChange() {
+ val pending = _uiState.value.pendingEmail
+ if (pending.isNullOrBlank() || _uiState.value.isResendingEmailChange) return
+ _uiState.update {
+ it.copy(isResendingEmailChange = true, emailChangeResent = false, errorMessage = null)
+ }
+ viewModelScope.launch {
+ when (val result = repository.requestEmailChange(pending)) {
+ is ApiResult.Success -> _uiState.update {
+ it.copy(isResendingEmailChange = false, emailChangeResent = true)
+ }
+ is ApiResult.Failure -> _uiState.update {
+ it.copy(
+ isResendingEmailChange = false,
+ errorMessage = result.error.toUserMessage(),
+ )
+ }
+ }
+ }
+ }
+
/**
* Deletes the account after the type-to-confirm guard passed. Requires the account's
* [email] (the server verifies both username and email). Emits
@@ -112,4 +171,6 @@ class AccountSettingsViewModel @Inject constructor(
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
fun acknowledgeEmailChange() = _uiState.update { it.copy(emailChangeRequested = false) }
+
+ fun acknowledgeEmailChangeResent() = _uiState.update { it.copy(emailChangeResent = false) }
}
diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt
index f274df5..087dd43 100644
--- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt
+++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt
@@ -820,6 +820,36 @@ class DefaultProfileRepositoryTest {
assertThat(recorded.body.readUtf8()).contains("\"newEmail\":\"new@example.com\"")
}
+ @Test
+ fun `getPendingEmailChange reads pendingEmail off the current user`() = runTest(testDispatcher) {
+ server.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """{ "user": { "id": "u1", "username": "adron", "email": "old@example.com",
+ "pendingEmail": "new@example.com" } }""",
+ ),
+ )
+
+ val result = repository.getPendingEmailChange()
+
+ assertThat(result).isInstanceOf(ApiResult.Success::class.java)
+ assertThat((result as ApiResult.Success).data).isEqualTo("new@example.com")
+ assertThat(server.takeRequest().path).isEqualTo("/api/user")
+ }
+
+ @Test
+ fun `getPendingEmailChange is null when no change is in flight`() = runTest(testDispatcher) {
+ // The live API sends an explicit null rather than omitting the key.
+ server.enqueue(
+ MockResponse().setResponseCode(200).setBody(
+ """{ "user": { "id": "u1", "username": "adron", "pendingEmail": null } }""",
+ ),
+ )
+
+ val result = repository.getPendingEmailChange()
+
+ assertThat((result as ApiResult.Success).data).isNull()
+ }
+
@Test
fun `requestEmailChange maps a 400 to a failure`() = runTest(testDispatcher) {
server.enqueue(MockResponse().setResponseCode(400).setBody("""{ "error": "Email already in use." }"""))
diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt
index 234f77b..bec54d3 100644
--- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt
+++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt
@@ -69,6 +69,102 @@ class AccountSettingsViewModelTest {
assertThat(vm.uiState.value.errorMessage).isNotNull()
}
+ // ---- pending email change ---------------------------------------------
+
+ @Test
+ fun `the pending change renders from the server's pendingEmail`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success("new@example.com")
+
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com")
+ }
+
+ @Test
+ fun `no pending change means no banner`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success(null)
+
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.pendingEmail).isNull()
+ }
+
+ @Test
+ fun `the pending change clears once the server reports it verified`() = runTest(dispatcher) {
+ // First read: still awaiting confirmation. Second read (after the user opened
+ // the emailed link): the server has cleared pendingEmail.
+ repo.pendingEmailChangeResults = ArrayDeque(
+ listOf(ApiResult.Success("new@example.com"), ApiResult.Success(null)),
+ )
+
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+ assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com")
+
+ vm.refreshPendingEmailChange()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.pendingEmail).isNull()
+ }
+
+ @Test
+ fun `a successful request puts the screen straight into the pending state`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success(null)
+ repo.requestEmailChangeResult = ApiResult.Success(Unit)
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ repo.pendingEmailChangeResult = ApiResult.Success("new@example.com")
+ vm.requestEmailChange("new@example.com")
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com")
+ }
+
+ @Test
+ fun `resend re-requests the change for the pending address`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success("new@example.com")
+ repo.requestEmailChangeResult = ApiResult.Success(Unit)
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ vm.resendEmailChange()
+ advanceUntilIdle()
+
+ assertThat(repo.requestedEmails).containsExactly("new@example.com")
+ assertThat(vm.uiState.value.emailChangeResent).isTrue()
+ assertThat(vm.uiState.value.isResendingEmailChange).isFalse()
+ }
+
+ @Test
+ fun `resend surfaces the server's message on failure`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success("new@example.com")
+ repo.requestEmailChangeResult =
+ ApiResult.Failure(AppError.Conflict("That email is already in use"))
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ vm.resendEmailChange()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value.emailChangeResent).isFalse()
+ assertThat(vm.uiState.value.errorMessage).isEqualTo("That email is already in use")
+ }
+
+ @Test
+ fun `resend does nothing when no change is pending`() = runTest(dispatcher) {
+ repo.pendingEmailChangeResult = ApiResult.Success(null)
+ val vm = AccountSettingsViewModel(repo)
+ advanceUntilIdle()
+
+ vm.resendEmailChange()
+ advanceUntilIdle()
+
+ assertThat(repo.requestedEmails).isEmpty()
+ }
+
@Test
fun `deleteAccount confirms with the seeded username and emits a signed-out effect`() = runTest(dispatcher) {
repo.currentUserFlow.value = testUser(username = "adron")
diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt
index ca04e5d..cd3ef35 100644
--- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt
+++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt
@@ -219,6 +219,8 @@ class FakeProfileRepository : ProfileRepository {
var identitiesResult: ApiResult> = ApiResult.Success(emptyList())
var unlinkIdentityResult: ApiResult = ApiResult.Success(Unit)
var requestEmailChangeResult: ApiResult = ApiResult.Success(Unit)
+ var pendingEmailChangeResults: ArrayDeque> = ArrayDeque()
+ var pendingEmailChangeResult: ApiResult = ApiResult.Success(null)
var deleteAccountResult: ApiResult = ApiResult.Success(Unit)
var sessionsCount = 0
@@ -226,6 +228,8 @@ class FakeProfileRepository : ProfileRepository {
var identitiesCount = 0
var unlinkedProvider: String? = null
var requestedEmail: String? = null
+ var requestedEmails: MutableList = mutableListOf()
+ var pendingEmailChangeCount = 0
var deleteAccountArgs: Pair? = null
override suspend fun getSessions(): ApiResult> {
@@ -250,9 +254,19 @@ class FakeProfileRepository : ProfileRepository {
override suspend fun requestEmailChange(newEmail: String): ApiResult {
requestedEmail = newEmail
+ requestedEmails += newEmail
return requestEmailChangeResult
}
+ /**
+ * Returns the next queued result, falling back to [pendingEmailChangeResult] so
+ * a test can either script a sequence (pending → cleared) or pin one value.
+ */
+ override suspend fun getPendingEmailChange(): ApiResult {
+ pendingEmailChangeCount++
+ return pendingEmailChangeResults.removeFirstOrNull() ?: pendingEmailChangeResult
+ }
+
override suspend fun deleteAccount(username: String, email: String): ApiResult {
deleteAccountArgs = username to email
return deleteAccountResult