Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)

// WorkManager + Hilt worker factory (bootstraps the documents delta-sync @HiltWorker)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)

// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<!-- Runtime-requested on Android 13+ so the background poll can raise tray notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".InterlinedListApplication"
Expand Down Expand Up @@ -49,5 +51,21 @@
<data android:scheme="interlinedlist" android:host="verify-email" />
</intent-filter>
</activity>

<!--
Remove the default WorkManager androidx.startup initializer so WorkManager
is initialized on demand from InterlinedListApplication (Configuration.Provider),
which wires in the HiltWorkerFactory needed by @HiltWorker workers.
-->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
package com.interlinedlist.android

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.interlinedlist.android.feature.notifications.push.SystemNotificationChannels
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

/** Application entry point; bootstraps the Hilt dependency graph. */
/**
* Application entry point; bootstraps the Hilt dependency graph.
*
* Also supplies the WorkManager [Configuration] on demand (paired with removing the
* default `androidx.startup` WorkManager initializer in the manifest) so `@HiltWorker`
* instances β€” such as the documents delta-sync worker and the notifications poll
* worker β€” can be constructed by Hilt.
*/
@HiltAndroidApp
class InterlinedListApplication : Application()
class InterlinedListApplication : Application(), Configuration.Provider {

@Inject
lateinit var workerFactory: HiltWorkerFactory

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()
// Register the system notification channels up front (idempotent, no-op < O)
// so the background poll can post into named channels the user can tune.
SystemNotificationChannels.ensureRegistered(this)
}
}
25 changes: 23 additions & 2 deletions app/src/main/java/com/interlinedlist/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.navigation.InterlinedListNavHost
import com.interlinedlist.android.navigation.NotificationLaunch
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

Expand All @@ -18,15 +24,30 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var sessionStore: SessionStore

@Inject
lateinit var themeSettingsStore: ThemeSettingsStore

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// Resolve the entry screen before composing so there's no login flash.
val startLoggedIn = sessionStore.isLoggedIn
// 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
enableEdgeToEdge()
setContent {
InterlinedListTheme {
InterlinedListNavHost(startLoggedIn = startLoggedIn)
val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle()
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
InterlinedListTheme(darkTheme = darkTheme) {
InterlinedListNavHost(
startLoggedIn = startLoggedIn,
notificationRoute = notificationRoute,
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
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 @@ -13,9 +18,13 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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 All @@ -36,6 +45,7 @@ import com.interlinedlist.android.feature.directmessages.navigation.navigateToNe
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute
import com.interlinedlist.android.feature.documents.sync.DocumentsSyncScheduler
import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute
import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute
import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute
Expand All @@ -56,6 +66,7 @@ import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute
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.ui.NotificationPreferencesRoute
import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute
import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute
Expand Down Expand Up @@ -177,7 +188,10 @@ private enum class HomeTab(val route: String, val label: String, val icon: Image
* back stack; sign-out returns to login.
*/
@Composable
fun InterlinedListNavHost(startLoggedIn: Boolean) {
fun InterlinedListNavHost(
startLoggedIn: Boolean,
notificationRoute: String? = null,
) {
val navController = rememberNavController()
NavHost(
navController = navController,
Expand All @@ -197,8 +211,14 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) {
)
}
composable(Routes.MAIN) {
val context = LocalContext.current
MainShell(
notificationRoute = notificationRoute,
onLoggedOut = {
// Stop background sync/poll for the signed-out session. Cancellation
// must never crash the sign-out flow, so any failure is swallowed.
runCatching { DocumentsSyncScheduler.cancelAll(context) }
runCatching { NotificationsSyncScheduler.cancelAll(context) }
navController.navigate(AuthRoutes.GRAPH) {
popUpTo(Routes.MAIN) { inclusive = true }
}
Expand All @@ -214,11 +234,45 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) {
* their own back navigation.
*/
@Composable
private fun MainShell(onLoggedOut: () -> Unit) {
private fun MainShell(
notificationRoute: String? = null,
onLoggedOut: () -> Unit,
) {
val tabNav = rememberNavController()
val backStackEntry by tabNav.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val onTabRoot = HomeTab.entries.any { it.route == currentRoute }
val context = LocalContext.current

// 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 */ }
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)
}
}

// Route straight to a tapped notification's destination once, when present.
val pendingRoute by rememberUpdatedState(notificationRoute)
LaunchedEffect(Unit) {
pendingRoute?.let { route ->
runCatching { tabNav.navigate(route) }
}
}

Scaffold(
bottomBar = {
Expand Down Expand Up @@ -345,6 +399,16 @@ private fun MainShell(onLoggedOut: () -> Unit) {

// ---- Documents ----
composable(Routes.DOCUMENTS) {
// Bootstrap the documents delta-sync: register the periodic pull/push and
// kick a one-shot sync when the Documents tab is opened. Scheduling must
// never crash the UI, so any failure is swallowed defensively.
val context = LocalContext.current
LaunchedEffect(Unit) {
runCatching {
DocumentsSyncScheduler.schedulePeriodic(context)
DocumentsSyncScheduler.syncNow(context)
}
}
DocumentsRoute(
onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) },
onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.interlinedlist.android.navigation

import android.content.Intent
import com.interlinedlist.android.feature.notifications.push.NotificationDeepLink

/**
* A pending in-app destination derived from a tapped system notification. The
* notifications module attaches [NotificationDeepLink] extras to the launch intent;
* this parses them into a concrete nav route (falling back to the notifications feed),
* so the tap lands the signed-in user on the right screen.
*/
data class NotificationLaunch(val route: String) {

companion object {

/**
* Parses [intent] into a [NotificationLaunch], or null when it did not
* originate from a notification tap. Unresolvable targets fall back to the
* in-app notifications feed.
*/
fun fromIntent(intent: Intent?): NotificationLaunch? {
if (intent?.getBooleanExtra(NotificationDeepLink.EXTRA_FROM_NOTIFICATION, false) != true) {
return null
}
val destination = runCatching {
NotificationDeepLink.Destination.valueOf(
intent.getStringExtra(NotificationDeepLink.EXTRA_DESTINATION).orEmpty(),
)
}.getOrDefault(NotificationDeepLink.Destination.NOTIFICATIONS)

val targetId = intent.getStringExtra(NotificationDeepLink.EXTRA_TARGET_ID)
return NotificationLaunch(routeFor(destination, targetId))
}

private fun routeFor(
destination: NotificationDeepLink.Destination,
targetId: String?,
): String = when (destination) {
NotificationDeepLink.Destination.MESSAGE ->
targetId?.let { Routes.messageDetail(it) } ?: Routes.NOTIFICATIONS
NotificationDeepLink.Destination.USER ->
targetId?.let { Routes.userProfile(it) } ?: Routes.NOTIFICATIONS
NotificationDeepLink.Destination.LIST ->
targetId?.let { Routes.listDetail(it) } ?: Routes.NOTIFICATIONS
NotificationDeepLink.Destination.NOTIFICATIONS -> Routes.NOTIFICATIONS
}
}
}
Loading
Loading