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
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.coroutines.android)
// Custom Tabs: the blog is server-rendered with no public JSON endpoint, so it is
// read in a themed Custom Tab rather than in-app.
implementation(libs.androidx.browser)

// DI
implementation(libs.hilt.android)
Expand All @@ -95,6 +98,7 @@ dependencies {

// Test
testImplementation(libs.junit)
testImplementation(libs.truth)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@
<data android:scheme="interlinedlist" android:host="verify-email-change" />
<data android:scheme="interlinedlist" android:host="undo-email-change" />
</intent-filter>

<!--
Blog links.

Only the app's OWN scheme is registered here. The public
https://interlinedlist.com/blog URLs are deliberately NOT claimed: the
blog is server-rendered and exposes no listing/post API, so the app has
nothing to render in-app and can only hand the URL to a browser. Claiming
a browser-bound link just to bounce it back out is a worse experience than
letting the browser have it — and the no-Custom-Tabs fallback (a plain
ACTION_VIEW) would resolve straight back into this activity. Revisit if a
real in-app renderer ever lands (see issue #87).
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="interlinedlist" android:host="blog" />
</intent-filter>
</activity>

<!--
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/com/interlinedlist/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.interlinedlist.android.blog.BlogLauncher
import com.interlinedlist.android.blog.BlogLink
import com.interlinedlist.android.core.datastore.SessionStore
import com.interlinedlist.android.core.datastore.ThemeMode
import com.interlinedlist.android.core.datastore.ThemeSettingsStore
Expand Down Expand Up @@ -40,6 +48,11 @@ class MainActivity : ComponentActivity() {
// 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)
// A tapped `interlinedlist://blog/...` link resolves to a public web URL rather
// than an in-app route: the blog is server-rendered and has no JSON feed, so it
// opens in a themed Custom Tab. The app deliberately does not claim the https
// blog URLs — see BlogLink.
val blogUrl = BlogLink.webUrlFor(intent?.dataString)
enableEdgeToEdge()
setContent {
val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle()
Expand All @@ -49,6 +62,19 @@ class MainActivity : ComponentActivity() {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
InterlinedListTheme(darkTheme = darkTheme) {
if (blogUrl != null) {
// Opened once per launch, not once per composition, so a rotation
// does not re-open the tab over the app.
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
var opened by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(blogUrl) {
if (!opened) {
opened = true
BlogLauncher.open(context, blogUrl, colorScheme)
}
}
}
InterlinedListNavHost(
startLoggedIn = startLoggedIn,
notificationRoute = notificationRoute,
Expand Down
91 changes: 91 additions & 0 deletions app/src/main/java/com/interlinedlist/android/blog/BlogLauncher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.interlinedlist.android.blog

import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb

/** How a blog URL ended up being opened — or that it could not be. */
enum class BlogLaunchResult {
/** Opened in a Custom Tab, themed from the app's colour scheme. */
CUSTOM_TAB,

/** No Custom Tabs-capable browser; opened in whatever browser handles the URL. */
BROWSER,

/** Nothing on the device can open a web URL. Nothing happened, nothing crashed. */
UNAVAILABLE,
}

/**
* Opens a blog URL in a Custom Tab themed to match the app.
*
* A Custom Tab is the honest ceiling for the blog today: it is server-rendered with no
* public JSON endpoint (see [BlogLink]), so the alternative would be scraping HTML.
* The tab is coloured from the live [ColorScheme], which `InterlinedListTheme` has
* already resolved from the user's System/Light/Dark setting, so the tab follows that
* setting without any extra plumbing.
*
* Custom Tabs are not guaranteed to exist: the fallback is a plain `ACTION_VIEW`
* intent, and if even that finds no handler the call is a no-op rather than a crash.
*/
object BlogLauncher {

/**
* Opens [url] and reports how. Never throws: a device with no browser at all
* yields [BlogLaunchResult.UNAVAILABLE].
*/
fun open(context: Context, url: String, colorScheme: ColorScheme): BlogLaunchResult {
val uri = Uri.parse(url)
return launchWithFallback(
customTab = { customTabsIntent(colorScheme).launchUrl(context, uri) },
browser = { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) },
)
}

/**
* The launch chain, free of Android types so it can be unit tested: try the Custom
* Tab, fall back to the browser, and swallow a failure of both.
*
* Both lambdas throw when nothing on the device can handle the intent
* (`ActivityNotFoundException`), which is a perfectly ordinary device
* configuration — so neither failure may escape.
*/
internal fun launchWithFallback(customTab: () -> Unit, browser: () -> Unit): BlogLaunchResult {
if (runCatching(customTab).isSuccess) return BlogLaunchResult.CUSTOM_TAB
if (runCatching(browser).isSuccess) return BlogLaunchResult.BROWSER
return BlogLaunchResult.UNAVAILABLE
}

/**
* True when [surface] is a dark-theme surface. The app theme's own surface colour
* is the single source of truth for light/dark here, so the tab matches whatever
* the user chose without the setting having to be threaded down to every call site.
*/
internal fun isDarkSurface(surface: Color): Boolean = surface.luminance() < 0.5f

private fun customTabsIntent(colorScheme: ColorScheme): CustomTabsIntent {
val colors = CustomTabColorSchemeParams.Builder()
.setToolbarColor(colorScheme.surface.toArgb())
.setSecondaryToolbarColor(colorScheme.surfaceVariant.toArgb())
.setNavigationBarColor(colorScheme.surface.toArgb())
.build()
return CustomTabsIntent.Builder()
.setShowTitle(true)
.setUrlBarHidingEnabled(true)
.setColorScheme(
if (isDarkSurface(colorScheme.surface)) {
CustomTabsIntent.COLOR_SCHEME_DARK
} else {
CustomTabsIntent.COLOR_SCHEME_LIGHT
},
)
.setDefaultColorSchemeParams(colors)
.build()
}
}
100 changes: 100 additions & 0 deletions app/src/main/java/com/interlinedlist/android/blog/BlogLink.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.interlinedlist.android.blog

import java.net.URI

/**
* Blog URLs, and the rules for turning a tapped link into one.
*
* The blog at `https://interlinedlist.com/blog` is **server-rendered** and the API
* exposes no public listing or post endpoint (see issue #87), so this app has nothing
* to render in-app: every blog destination resolves to a web URL that is handed to a
* Custom Tab. That makes this object the whole "data layer" for the blog — deliberately.
*
* Kept as plain JVM code (no `android.net.Uri`) so the matching rules are covered by
* fast unit tests, matching the `EmailChangeLink` precedent in `:feature:auth`.
*
* ## Which links the app claims
*
* The manifest registers **only** the app's own `interlinedlist://blog…` scheme. The
* `https://interlinedlist.com/blog…` web URLs are deliberately *not* claimed: with no
* in-app renderer, claiming them would intercept a browser-bound link only to hand it
* straight back to a browser — losing the user's own browser session and, when no
* Custom Tabs provider exists, resolving the plain `ACTION_VIEW` fallback back into
* this very activity. [webUrlFor] still recognises the web form so in-app call sites
* may pass either shape, and so the claim can be switched on in the manifest without
* touching these rules if a real in-app renderer ever lands.
*/
object BlogLink {

/** Host of the public site; also matched with a `www.` prefix. */
const val WEB_HOST = "interlinedlist.com"

/** Custom scheme the app registers for its own blog links. */
const val APP_SCHEME = "interlinedlist"

/** First path segment (web) / authority (custom scheme) identifying the blog. */
const val BLOG_SEGMENT = "blog"

/** Canonical URL of the blog index — the Account hub's Blog entry point. */
const val INDEX_URL = "https://$WEB_HOST/$BLOG_SEGMENT"

private val WEB_SCHEMES = setOf("https", "http")

/** Canonical URL of a single post, by slug. */
fun postUrl(slug: String): String = "$INDEX_URL/${slug.trim('/')}"

/**
* Resolves [uri] to the canonical blog URL it names, or null when it is not a blog
* link at all.
*
* Recognised shapes (scheme/host case-insensitive; trailing slash, query and
* fragment tolerated and dropped):
* - `interlinedlist://blog` and `interlinedlist://blog/<slug>`
* - `https://interlinedlist.com/blog` and `https://interlinedlist.com/blog/<slug>`
*
* Anything else — a foreign or look-alike host, a `/blogroll`-style prefix trap, a
* `..` traversal segment, or a string that is not a URI at all — yields null rather
* than an exception or a URL we would then open in a browser.
*/
fun webUrlFor(uri: String?): String? {
val trimmed = uri?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return null

val segments = parsed.blogPathSegments() ?: return null
// Percent-encoding is preserved verbatim: the raw path is already a valid URL
// component, and re-encoding it here would corrupt non-ASCII slugs.
return if (segments.isEmpty()) INDEX_URL else "$INDEX_URL/${segments.joinToString("/")}"
}

/**
* The post path under `/blog` (empty for the index itself), or null when this URI
* is not a blog link.
*/
private fun URI.blogPathSegments(): List<String>? {
val scheme = scheme?.lowercase() ?: return null
val segments = when {
scheme in WEB_SCHEMES -> {
val host = host?.lowercase()?.removePrefix("www.") ?: return null
if (host != WEB_HOST) return null
val path = rawPath.orEmpty().trim('/').splitPath()
if (path.firstOrNull()?.lowercase() != BLOG_SEGMENT) return null
path.drop(1)
}
scheme == APP_SCHEME -> {
// `interlinedlist://blog/<slug>` — "blog" sits in the authority.
if (host?.lowercase() != BLOG_SEGMENT) return null
rawPath.orEmpty().trim('/').splitPath()
}
else -> return null
}
// A relative segment would silently point the browser somewhere other than the
// blog, so refuse rather than normalise.
if (segments.any { it == "." || it == ".." }) return null
return segments
}

/** Splits an already-trimmed path, dropping the empty parts left by `//`. */
private fun String.splitPath(): List<String> =
if (isEmpty()) emptyList() else split('/').filter { it.isNotEmpty() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
Expand All @@ -32,6 +33,8 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.interlinedlist.android.blog.BlogLauncher
import com.interlinedlist.android.blog.BlogLink
import com.interlinedlist.android.feature.auth.nav.AuthRoutes
import com.interlinedlist.android.feature.auth.nav.authGraph
import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations
Expand Down Expand Up @@ -501,6 +504,8 @@ private fun MainShell(
// Sign-out reuses the existing auth-backed logout; the profile
// module intentionally owns no session state.
val logoutViewModel: HomeViewModel = hiltViewModel()
val accountContext = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
ProfileRoute(
onEditProfile = { tabNav.navigate(Routes.PROFILE_EDIT) },
onSearchUsers = { tabNav.navigate(Routes.USER_SEARCH) },
Expand All @@ -515,6 +520,11 @@ private fun MainShell(
onOpenBlockedMuted = { tabNav.navigate(Routes.ACCOUNT_BLOCKED_MUTED) },
onOpenAccountSettings = { tabNav.navigate(Routes.ACCOUNT_SETTINGS) },
onOpenSettings = { tabNav.navigate(Routes.SETTINGS) },
// Leaves the app rather than navigating: the blog is server-rendered
// with no listing API, so it opens in a themed Custom Tab.
onOpenBlog = {
BlogLauncher.open(accountContext, BlogLink.INDEX_URL, colorScheme)
},
onSignOut = { logoutViewModel.logout(onLoggedOut) },
)
}
Expand Down
Loading
Loading