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
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
package com.interlinedlist.android.navigation

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
Expand All @@ -24,7 +19,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
Expand Down Expand Up @@ -67,6 +61,7 @@ import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute
import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute
import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute
import com.interlinedlist.android.feature.notifications.push.NotificationsSyncScheduler
import com.interlinedlist.android.feature.notifications.push.PushRegistrationViewModel
import com.interlinedlist.android.feature.notifications.ui.NotificationPreferencesRoute
import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute
import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute
Expand Down Expand Up @@ -250,26 +245,29 @@ private fun MainShell(

// Bootstrap the notification poll for the signed-in session: register the periodic
// near-real-time poll and kick a one-shot so the last-seen marker seeds immediately.
// On Android 13+ request POST_NOTIFICATIONS first (silently ignored below 13, where
// the permission does not exist). Scheduling must never crash the shell, so failures
// are swallowed. Runs once when the shell enters.
val requestNotificationsPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* result ignored: the poll still runs; posting is a no-op if denied */ }
// Scheduling must never crash the shell, so failures are swallowed. Runs once when
// the shell enters.
//
// POST_NOTIFICATIONS is deliberately NOT requested here any more: a cold-start
// prompt arrives with no context and spends one of Android 13's two attempts for
// nothing. It is asked instead at the moment the user switches a "Push" channel on
// in Notification preferences (see NotificationPreferencesRoute). The poll runs
// either way, and posting is already a no-op when the permission is absent.
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) requestNotificationsPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
runCatching {
NotificationsSyncScheduler.schedulePeriodic(context)
NotificationsSyncScheduler.syncNow(context)
}
}

// Device-token lifecycle. Entering this shell is exactly "app launch while signed
// in" plus "just signed in", which is where the push docs want a re-registration;
// collection then continues for the session so a rotated token re-registers too.
// The matching unregister hangs off the auth module's sign-out teardown, so it
// cannot be skipped by whichever exit the user takes.
val pushRegistration: PushRegistrationViewModel = hiltViewModel()
LaunchedEffect(Unit) { pushRegistration.runForSession() }

// Route straight to a tapped notification's destination once, when present.
val pendingRoute by rememberUpdatedState(notificationRoute)
LaunchedEffect(Unit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.interlinedlist.android.core.common.session

/**
* A unit of work that must run while the session is still usable, immediately
* before it is torn down by sign-out or account deletion.
*
* Declared here alongside [SessionTokenProvider] for the same reason: `:feature:auth`
* owns the single sign-out path and needs to run contributed teardown steps without
* depending on the feature modules that contribute them (which would invert the module
* graph). Implementations are contributed with Dagger's `@IntoSet`, so adding one is a
* one-line `@Binds @IntoSet` in the owning feature module — no change here or in
* `:feature:auth`.
*
* Contract for implementations:
* - the bearer token is still persisted, so authenticated calls are allowed;
* - be idempotent — teardown may run for a session that was already partly cleaned up;
* - failures are swallowed by the caller; a broken step must never strand a user
* signed in.
*/
interface SessionTeardownTask {

/** Runs while the session's bearer token is still valid. */
suspend fun onSessionEnding()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.database.dao.UserDao
import com.interlinedlist.android.core.database.entity.CachedUserEntity
import com.interlinedlist.android.core.datastore.SessionStore
import com.interlinedlist.android.core.common.session.SessionTeardownTask
import com.interlinedlist.android.core.model.User
import com.interlinedlist.android.core.network.api.InterlinedListApi
import com.interlinedlist.android.core.network.dto.SyncTokenRequest
Expand All @@ -26,6 +27,12 @@ class DefaultAuthRepository @Inject constructor(
private val userDao: UserDao,
private val json: Json,
private val dispatchers: DispatcherProvider,
/**
* Steps contributed by other feature modules that must run while the session is
* still valid (e.g. unregistering this device's push token). Dagger supplies an
* empty set when nothing contributes — see `AuthModule.sessionTeardownTasks`.
*/
private val sessionTeardownTasks: Set<@JvmSuppressWildcards SessionTeardownTask>,
) : AuthRepository {

override fun isLoggedIn(): Boolean = sessionStore.isLoggedIn
Expand Down Expand Up @@ -85,6 +92,12 @@ class DefaultAuthRepository @Inject constructor(
}

override suspend fun logout() = withContext(dispatchers.io) {
// Teardown runs FIRST, while the bearer token is still persisted, because the
// contributed steps make authenticated calls (push-token unregister). This is
// the app's single sign-out path — account deletion funnels through it too —
// so a teardown step cannot be skipped by some other exit. A failing step must
// never strand the user signed in, so each is individually guarded.
sessionTeardownTasks.forEach { task -> runCatching { task.onSessionEnding() } }
sessionStore.clear()
userDao.clear()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.interlinedlist.android.feature.auth.di

import com.interlinedlist.android.core.common.session.SessionTeardownTask
import com.interlinedlist.android.feature.auth.data.AuthRepository
import com.interlinedlist.android.feature.auth.data.DefaultAuthRepository
import com.interlinedlist.android.feature.auth.data.remote.AuthApi
Expand All @@ -8,6 +9,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import retrofit2.Retrofit
import javax.inject.Singleton

Expand All @@ -18,6 +20,15 @@ abstract class AuthModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: DefaultAuthRepository): AuthRepository

/**
* Declares the set of session-teardown steps run on sign-out / account deletion so
* it can be injected even when no module contributes one (Dagger then supplies an
* empty set). Feature modules opt in with `@Binds @IntoSet` — see
* `PushTokenSessionTeardown` in `:feature:notifications`.
*/
@Multibinds
abstract fun sessionTeardownTasks(): Set<SessionTeardownTask>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.auth.data
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.core.common.session.SessionTeardownTask
import com.interlinedlist.android.core.datastore.SessionStore
import com.interlinedlist.android.core.network.api.InterlinedListApi
import com.interlinedlist.android.feature.auth.data.remote.AuthApi
Expand Down Expand Up @@ -53,13 +54,16 @@ class DefaultAuthRepositoryTest {
@After
fun tearDown() = server.shutdown()

private fun repository() = DefaultAuthRepository(
private fun repository(
teardownTasks: Set<SessionTeardownTask> = emptySet(),
) = DefaultAuthRepository(
api = api,
authApi = authApi,
sessionStore = session,
userDao = dao,
json = json,
dispatchers = TestDispatcherProvider(dispatcher),
sessionTeardownTasks = teardownTasks,
)

private fun enqueue(code: Int, body: String = "") {
Expand Down Expand Up @@ -200,4 +204,66 @@ class DefaultAuthRepositoryTest {
assertThat(result).isInstanceOf(ApiResult.Success::class.java)
assertThat(server.takeRequest().path).contains("api/auth/send-verification-email")
}

// ---- sign-out / account deletion teardown ------------------------------

@Test
fun `sign-out runs session teardown while the token is still valid, then clears it`() =
runTest(dispatcher) {
session.saveToken("il_tok_abc")
session.userId = "u1"
val teardown = RecordingTeardown(session)

repository(teardownTasks = setOf(teardown)).logout()

assertThat(teardown.runCount).isEqualTo(1)
// The push-token unregister is an authenticated call, so the token MUST
// still be readable at teardown time.
assertThat(teardown.tokenSeen).isEqualTo("il_tok_abc")
assertThat(session.currentToken()).isNull()
assertThat(dao.cleared).isTrue()
}

@Test
fun `account deletion signs out through the same path, so teardown still runs`() =
runTest(dispatcher) {
// AccountSettings deletes the account and then calls this very logout(), so
// there is no second sign-out path that could bypass the teardown hook.
session.saveToken("il_tok_abc")
val teardown = RecordingTeardown(session)

repository(teardownTasks = setOf(teardown)).logout()

assertThat(teardown.runCount).isEqualTo(1)
assertThat(teardown.tokenSeen).isEqualTo("il_tok_abc")
assertThat(session.currentToken()).isNull()
}

@Test
fun `a failing teardown step never strands the user signed in`() = runTest(dispatcher) {
session.saveToken("il_tok_abc")
val exploding = object : SessionTeardownTask {
override suspend fun onSessionEnding() = error("push unregister failed")
}
val teardown = RecordingTeardown(session)

repository(teardownTasks = setOf(exploding, teardown)).logout()

assertThat(teardown.runCount).isEqualTo(1) // the other step still ran
assertThat(session.currentToken()).isNull()
assertThat(dao.cleared).isTrue()
}
}

/** Records that teardown ran, and what the session looked like at that moment. */
private class RecordingTeardown(private val session: SessionStore) : SessionTeardownTask {
var runCount = 0
private set
var tokenSeen: String? = null
private set

override suspend fun onSessionEnding() {
runCount++
tokenSeen = session.currentToken()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.interlinedlist.android.feature.notifications.data

import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.map
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.feature.notifications.data.remote.PushApi
import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationRequest
import com.interlinedlist.android.feature.notifications.data.remote.dto.PushUnregisterRequest
import com.interlinedlist.android.feature.notifications.push.PushEnvironment
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import javax.inject.Inject

/**
* `POST /api/push/register` + `DELETE /api/push/unregister` over the shared authed
* Retrofit stack. Stateless on purpose: WHEN to call these is the lifecycle's job
* ([com.interlinedlist.android.feature.notifications.push.PushRegistrationManager]).
*/
class DefaultPushRegistrationRepository @Inject constructor(
private val api: PushApi,
private val environment: PushEnvironment,
private val json: Json,
private val dispatchers: DispatcherProvider,
) : PushRegistrationRepository {

override suspend fun register(token: String): ApiResult<Unit> = withContext(dispatchers.io) {
safeApiCall(json) {
api.register(
PushRegistrationRequest(
token = token,
// The server accepts exactly "ios" or "android".
platform = ANDROID_PLATFORM,
environment = environment.apiValue,
),
)
}.map { }
}

override suspend fun unregister(token: String): ApiResult<Unit> = withContext(dispatchers.io) {
safeApiCall(json) { api.unregister(PushUnregisterRequest(token)) }
}

private companion object {
const val ANDROID_PLATFORM = "android"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.interlinedlist.android.feature.notifications.data

import com.interlinedlist.android.core.common.result.ApiResult

/**
* The device-token half of push notifications: tells the server which device the
* signed-in account's pushes should go to, and — just as importantly — that they
* should stop.
*/
interface PushRegistrationRepository {

/**
* Registers (or updates) [token] for the signed-in user as an `android` device.
* Re-registering a token the server already knows updates the existing record, so
* this is safe to call on every launch.
*/
suspend fun register(token: String): ApiResult<Unit>

/**
* Removes [token]'s registration. Idempotent — an unknown token still succeeds —
* so it can be called defensively on sign-out without tracking what was registered.
*/
suspend fun unregister(token: String): ApiResult<Unit>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.interlinedlist.android.feature.notifications.data.remote

import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationRequest
import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationResponse
import com.interlinedlist.android.feature.notifications.data.remote.dto.PushUnregisterRequest
import retrofit2.http.Body
import retrofit2.http.HTTP
import retrofit2.http.POST

/**
* Retrofit description of the device-token endpoints. Provided from the shared,
* already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), so both
* calls carry the current session's token — which is what binds a device token to an
* account, and why [unregister] must run BEFORE the session is cleared on sign-out.
*/
interface PushApi {

/** Registers (or updates) this device's push token for the signed-in user. */
@POST("api/push/register")
suspend fun register(@Body body: PushRegistrationRequest): PushRegistrationResponse

/**
* Removes this device's registration. `@HTTP(hasBody = true)` rather than `@DELETE`
* because Retrofit's `@DELETE` forbids a body and the server expects the token in one.
*/
@HTTP(method = "DELETE", path = "api/push/unregister", hasBody = true)
suspend fun unregister(@Body body: PushUnregisterRequest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.interlinedlist.android.feature.notifications.data.remote.dto

import kotlinx.serialization.Serializable

/**
* Body for `POST /api/push/register`, per the published contract
* (https://interlinedlist.com/help/api/push-notifications):
*
* ```
* { "token": "<base64-or-hex device token>", "platform": "android", "environment": "production" }
* ```
*
* [platform] must be exactly `ios` or `android`; [environment] is optional
* (`sandbox` / `production`, inferred server-side when omitted) but we always send it
* so a debug install can never have its token treated as a production device.
* Re-registering an existing token updates the record rather than duplicating it.
*/
@Serializable
data class PushRegistrationRequest(
val token: String,
val platform: String,
val environment: String,
)

/**
* Response for `POST /api/push/register`: `{ "registered": true }`. Defaulted and
* decoded with the shared `ignoreUnknownKeys` Json, so an empty or extended body
* still parses; the HTTP status is what decides success.
*/
@Serializable
data class PushRegistrationResponse(
val registered: Boolean = true,
)

/**
* Body for `DELETE /api/push/unregister`: `{ "token": … }`. The token travels in the
* REQUEST BODY (not a query parameter), and the call is idempotent — unregistering an
* unknown token still returns 200.
*/
@Serializable
data class PushUnregisterRequest(
val token: String,
)
Loading
Loading