The app's half of the in-app channel. Phase 7 shipped four inbox routes with no consumer on either platform; this is the Android one, plus the per-channel preferences Phase 3 added and the shipped screen could not express. The drawer's "Notifications" is the INBOX now, with the preferences one tap away behind its gear — the arrangement Phase 7 shipped on the web, and what a person means when they tap the word. The settings screen moved off /notifications/subscriptions onto /notifications/channels: it renders a control per channel that applies to each id (from the item's own `channels`, never a hardcoded three) and per mode that channel accepts, which is how email's `digest` reaches the app. The old endpoint is the push projection of the new table server-side, so the shipped APK went on working the whole time. A tapped tickle whose `ref` starts with `notification:` lands on the inbox whatever its stream is — an engagement rule's stream id is a TRIGGER id in the one namespace, and `forStream`'s fixed map would have sent most of them Home. Every other tickle keeps the route it has always had. The ref is not decoded beyond that prefix and never rendered: it is a hint that a row exists, and the contract stays wake-and-pull. PLAN.md §7's "no Room cache in v1" stands; the offline snapshot is its one named exception, settled with the org lead. The inbox is a short, read-only, newest-first list with a server-side cursor, so what "works offline" needs is the newest page and the badge, not a database — one JSON blob in the DataStore the push code already uses. Every snapshot is scoped to (base URL, user id) and only handed back to that pair: that, not the clear-on-logout, is what stops a cache surviving into another account on the paths that never reach a logout at all. Co-Authored-By: Claude <noreply@anthropic.com>
159 lines
7.5 KiB
Kotlin
159 lines
7.5 KiB
Kotlin
/*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
package com.runicgateway.app
|
|
|
|
import android.content.Intent
|
|
import android.graphics.Color
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.SystemBarStyle
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.runtime.CompositionLocalProvider
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
|
import com.runicgateway.app.core.push.PushNotifier
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.runicgateway.app.ui.AppViewModel
|
|
import com.runicgateway.app.ui.AppViewModel.AppState
|
|
import com.runicgateway.app.ui.LocalAssetResolver
|
|
import com.runicgateway.app.ui.RunicApp
|
|
import com.runicgateway.app.ui.components.LoadingView
|
|
import com.runicgateway.app.ui.connect.ConnectScreen
|
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
|
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import kotlinx.coroutines.launch
|
|
import javax.inject.Inject
|
|
|
|
/**
|
|
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
|
* connect screen until a shard site is configured (§3), then the main app.
|
|
* The Material theme is resolved from the shard's published appearance (M12),
|
|
* and asset-path resolution is provided to the whole tree.
|
|
*/
|
|
@AndroidEntryPoint
|
|
class MainActivity : ComponentActivity() {
|
|
|
|
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
|
|
// §4.2). Field-injected because the callback can arrive independent of any
|
|
// ViewModel; a successful exchange flips the SessionManager the whole app
|
|
// observes, and the login screen consumes SsoAuthManager.outcome.
|
|
@Inject
|
|
lateinit var ssoAuthManager: SsoAuthManager
|
|
|
|
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
|
|
// Set from the launching intent and from onNewIntent (the activity is singleTop),
|
|
// consumed once by RunicApp which navigates to the stream's screen.
|
|
private var pendingStream by mutableStateOf<String?>(null)
|
|
|
|
// The tickle's other half: an opaque ref, carried since M7 and read since
|
|
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
|
|
// an inbox row and the tap should land there. Never rendered — it is a hint that
|
|
// something exists, and the app pulls the real item over the authenticated API.
|
|
private var pendingRef by mutableStateOf<String?>(null)
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
|
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
|
handleSsoCallback(intent)
|
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
|
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
|
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
|
|
setContent {
|
|
val appViewModel: AppViewModel = hiltViewModel()
|
|
val state by appViewModel.state.collectAsStateWithLifecycle()
|
|
|
|
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
|
|
// resolved token map is applied field by field over the shipped palette,
|
|
// so NONE — before the site is connected, or when settings can't be
|
|
// read — is the app exactly as it shipped.
|
|
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
|
|
|
|
// The admin's theme and nav can change while the app is backgrounded
|
|
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
|
|
// re-validation RunicApp already does. Best-effort and silent.
|
|
LifecycleResumeEffect(Unit) {
|
|
appViewModel.refreshAppearance()
|
|
onPauseOrDispose { }
|
|
}
|
|
|
|
RunicGatewayTheme(appearance = appearance) {
|
|
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize(),
|
|
color = MaterialTheme.colorScheme.background,
|
|
) {
|
|
when (val s = state) {
|
|
AppState.Loading -> LoadingView()
|
|
AppState.NeedsConnection ->
|
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
|
is AppState.Ready ->
|
|
RunicApp(
|
|
appearance = s.appearance,
|
|
onChangeServer = appViewModel::changeServer,
|
|
deepLinkStream = pendingStream,
|
|
deepLinkRef = pendingRef,
|
|
onDeepLinkConsumed = {
|
|
pendingStream = null
|
|
pendingRef = null
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A notification tap or an SSO callback arriving while the activity is already
|
|
* running (singleTop) — the common case, since the Custom Tab overlays the live
|
|
* app during sign-in.
|
|
*/
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
setIntent(intent)
|
|
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let {
|
|
pendingStream = it
|
|
// Cleared alongside, not conditionally: a tickle with no ref arriving
|
|
// after one with a ref must not inherit the earlier ref and land on the
|
|
// inbox instead of its own screen.
|
|
pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF)
|
|
}
|
|
handleSsoCallback(intent)
|
|
}
|
|
|
|
/**
|
|
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
|
|
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
|
|
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
|
|
* docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
|
|
* on `SsoAuthManager.outcome` (success signs the session in; failure shows on the
|
|
* login screen). Non-callback intents are ignored.
|
|
*/
|
|
private fun handleSsoCallback(intent: Intent?) {
|
|
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
|
|
val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
|
|
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
|
|
if (!isCallback) return
|
|
val state = data.getQueryParameter("state")
|
|
val code = data.getQueryParameter("code")
|
|
val error = data.getQueryParameter("error")
|
|
lifecycleScope.launch { ssoAuthManager.complete(state = state, code = code, error = error) }
|
|
}
|
|
}
|