Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
<!--
App Links: verified https:// deep links routed to the in-app nav
destinations (shared list/document tokens, password reset, email
verification). autoVerify enables Android App Links verification.
verification, and the confirm/undo halves of an email change).
autoVerify enables Android App Links verification.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
Expand All @@ -38,6 +39,8 @@
<data android:pathPrefix="/documents/shared/" />
<data android:path="/reset-password" />
<data android:path="/verify-email" />
<data android:path="/verify-email-change" />
<data android:path="/undo-email-change" />
</intent-filter>

<!-- Custom-scheme equivalents for the same in-app deep-link routes. -->
Expand All @@ -49,6 +52,8 @@
<data android:scheme="interlinedlist" android:host="documents" />
<data android:scheme="interlinedlist" android:host="reset-password" />
<data android:scheme="interlinedlist" android:host="verify-email" />
<data android:scheme="interlinedlist" android:host="verify-email-change" />
<data android:scheme="interlinedlist" android:host="undo-email-change" />
</intent-filter>
</activity>

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/interlinedlist/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -47,6 +52,7 @@ class MainActivity : ComponentActivity() {
InterlinedListNavHost(
startLoggedIn = startLoggedIn,
notificationRoute = notificationRoute,
emailChangeRoute = emailChangeRoute,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) } }
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ interface AuthRepository {
/** Resends the verification email to the signed-in (unverified) user. */
suspend fun resendVerificationEmail(): ApiResult<Unit>

/**
* 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<Unit>

/**
* 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<Unit>

/** Clears the persisted session and cached user. */
suspend fun logout()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,6 +86,16 @@ class DefaultAuthRepository @Inject constructor(
safeApiCall(json) { authApi.sendVerificationEmail() }
}

override suspend fun verifyEmailChange(token: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { authApi.verifyEmailChange(VerifyEmailChangeRequest(token)) }
}

override suspend fun undoEmailChange(token: String): ApiResult<Unit> =
withContext(dispatchers.io) {
safeApiCall(json) { authApi.undoEmailChange(UndoEmailChangeRequest(token)) }
}

override suspend fun logout() = withContext(dispatchers.io) {
sessionStore.clear()
userDao.clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,18 +43,50 @@ object AuthRoutes {
* `<intent-filter>` 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<NavDeepLink> = 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<NavDeepLink> = 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<NavDeepLink> 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<NavDeepLink> 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) }
}

/**
Expand Down Expand Up @@ -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(
Expand Down
Loading