Skip to content

Commit b35fabd

Browse files
adsamcikCopilot
andcommitted
feat(network): add NetworkPolicyContributor/Aggregator for multi-consumer policy composition
Currently MapStore is the sole writer of NetworkGateway state via setEnabled+setPolicy. As soon as a second consumer (e.g. OSM PMTiles offline pre-cache download) wants its own allowlist+rate limit, both consumers fight for the global state and last writer wins. Introduce a contributor/aggregator pattern in :network: * NetworkPolicyContributor exposes a StateFlow<NetworkPolicyContribution> with stable id, allowing features to publish their current 'what hosts I need' view reactively. Inactive == not contributing. * NetworkPolicyAggregator (Singleton) combines every contributor and pushes the union policy onto the gateway. Kill switch armed iff any contributor active; allowedHosts = set union; perHostRateLimit/Window = MAX (so a bursty contributor isn't capped by a quieter peer). * Empty-contributors degenerate case is explicit: combine() over an empty Iterable never emits, so the aggregator short-circuits and leaves the gateway at its deny-all default (no observable signal otherwise). * Policy is published BEFORE the kill switch on every emission so the interceptor chain sees the right allowlist on the very first request after a 0->1 transition and stays consistent on a 1->0 transition. Tests cover: all-inactive, single-active, two-disjoint, two-overlapping, MAX-window selection, partial hot-swap, full hot-swap, and zero-contributors edge case. Uses FakeNetworkGateway + TestScope + a tiny MutableStateFlow-backed fake contributor — no Hilt in tests, keeping :network free of DI infrastructure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dfd22d4 commit b35fabd

3 files changed

Lines changed: 498 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.adsamcik.tracker.network
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Job
5+
import kotlinx.coroutines.flow.combine
6+
import kotlinx.coroutines.launch
7+
import javax.inject.Inject
8+
import javax.inject.Singleton
9+
10+
/**
11+
* Single owner of [NetworkGateway.setEnabled] + [NetworkGateway.setPolicy].
12+
*
13+
* Subscribes to every registered [NetworkPolicyContributor] and pushes the
14+
* union of their active contributions onto the gateway. Construction launches
15+
* a coroutine on the supplied [scope] that lives for the entire process — the
16+
* aggregator is intended to be a `@Singleton`, eagerly instantiated from
17+
* `Application.onCreate` so the gateway is always in a consistent state by
18+
* the time any feature surfaces UI.
19+
*
20+
* # Composition semantics
21+
*
22+
* - **Kill switch.** The gateway is enabled iff at least one contributor is
23+
* [NetworkPolicyContribution.Active]. With zero active contributors the
24+
* kill switch flips OFF and the gateway publishes [NetworkPolicy.EMPTY],
25+
* which fail-closes every subsequent request. This matches the privacy
26+
* contract: "opt-in is the only safe default".
27+
*
28+
* - **Allowed hosts.** Union of every active contributor's
29+
* [NetworkPolicyContribution.Active.allowedHosts]. Overlapping hosts are
30+
* deduplicated (set semantics).
31+
*
32+
* - **Rate limits.** A single per-host rate limit applies to every host in
33+
* the effective policy. When multiple contributors are active we take the
34+
* MAX of their requested limits — the alternative (MIN) would let a quiet
35+
* contributor throttle a bursty one even on hosts the bursty contributor
36+
* is the sole consumer of. The same logic applies to
37+
* [NetworkPolicyContribution.Active.perHostRateWindowMs] (MAX window =
38+
* most permissive). Per-host-within-policy granularity is a future
39+
* extension that requires [NetworkPolicy] to grow a `Map<String, Int>`.
40+
*
41+
* # Zero-contributors degenerate case
42+
*
43+
* Hilt's `@Multibinds` may resolve to an empty set if no module contributes.
44+
* In that case the constructor short-circuits: no collector is launched and
45+
* the gateway is left at its initial deny-all state. This is safe (no traffic
46+
* flows) and the empty-set path is covered by tests so a future refactor
47+
* that accidentally unbinds every contributor still surfaces clearly.
48+
*
49+
* # Why a class, not an init-time helper
50+
*
51+
* Holding the collector job in [collectorJob] gives tests a synchronous
52+
* signal that the subscription is live ([isCollecting]) and lets future
53+
* shutdown paths cancel cleanly without yanking the whole [scope].
54+
*
55+
* @param gateway The singleton [NetworkGateway] to drive. Constructor-injected
56+
* so tests can swap a [FakeNetworkGateway].
57+
* @param contributors Every [NetworkPolicyContributor] currently registered.
58+
* Hilt's `@JvmSuppressWildcards` annotation on the call site keeps the
59+
* `Set<NetworkPolicyContributor>` type stable across Kotlin/Java boundaries
60+
* for multibindings; consumers in `:network` (tests) can pass any `Set`.
61+
* @param scope Long-lived coroutine scope (typically `@ApplicationScope`)
62+
* that owns the combine collector for the process lifetime.
63+
*/
64+
@Singleton
65+
class NetworkPolicyAggregator @Inject constructor(
66+
private val gateway: NetworkGateway,
67+
private val contributors: Set<@JvmSuppressWildcards NetworkPolicyContributor>,
68+
private val scope: CoroutineScope,
69+
) {
70+
71+
private val collectorJob: Job? = startCollector()
72+
73+
/**
74+
* `true` once the combine collector is running. Always `false` when zero
75+
* contributors are registered (the degenerate, safe path). Useful in
76+
* tests that need to assert the aggregator wired up at all without
77+
* waiting on a [NetworkGateway] state change.
78+
*/
79+
val isCollecting: Boolean
80+
get() = collectorJob?.isActive == true
81+
82+
private fun startCollector(): Job? {
83+
if (contributors.isEmpty()) {
84+
// kotlinx.coroutines.flow.combine over an empty Iterable never
85+
// emits, so launching a collector would leave the gateway at its
86+
// initial deny-all state with no observable signal. Make the
87+
// no-contributor case explicit instead of relying on combine's
88+
// silent never-emit behaviour. Gateway stays disabled / EMPTY by
89+
// its own default — exactly what we'd publish anyway.
90+
return null
91+
}
92+
val flows = contributors.map { it.contribution }
93+
return scope.launch {
94+
combine(flows) { snapshot -> aggregate(snapshot.toList()) }
95+
.collect { (enabled, policy) ->
96+
// Publish policy BEFORE arming the kill switch so the
97+
// interceptor chain sees the right allowlist on the very
98+
// first request that flows after a 0→1 transition.
99+
// Conversely on a 1→0 transition publishing policy first
100+
// (now EMPTY) means in-flight requests that race the
101+
// kill switch are also rejected by the allowlist gate —
102+
// belt-and-braces.
103+
gateway.setPolicy(policy)
104+
gateway.setEnabled(enabled)
105+
}
106+
}
107+
}
108+
109+
private fun aggregate(
110+
contributions: List<NetworkPolicyContribution>,
111+
): Pair<Boolean, NetworkPolicy> {
112+
val active = contributions.filterIsInstance<NetworkPolicyContribution.Active>()
113+
if (active.isEmpty()) return false to NetworkPolicy.EMPTY
114+
val allHosts = active.flatMapTo(HashSet()) { it.allowedHosts }
115+
val maxLimit = active.maxOf { it.perHostRateLimit }
116+
val maxWindow = active.maxOf { it.perHostRateWindowMs }
117+
return true to NetworkPolicy(
118+
allowedHosts = allHosts,
119+
perHostRateLimit = maxLimit,
120+
perHostRateWindowMs = maxWindow,
121+
)
122+
}
123+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.adsamcik.tracker.network
2+
3+
import kotlinx.coroutines.flow.StateFlow
4+
5+
/**
6+
* A feature-scoped contributor that publishes its current view of "what hosts
7+
* I need network access to, with what rate limits". The
8+
* [NetworkPolicyAggregator] combines every contributor's [contribution] into
9+
* the single effective [NetworkPolicy] driven onto the [NetworkGateway].
10+
*
11+
* # Why contributors instead of direct gateway calls
12+
*
13+
* Historically `MapStore` was the SOLE writer of [NetworkGateway] state via
14+
* [NetworkGateway.setEnabled] + [NetworkGateway.setPolicy]. Any second
15+
* consumer (e.g. an OSM PMTiles offline pre-cache download) wanting its own
16+
* allowlist + rate limit would fight `MapStore` for the global state — last
17+
* writer wins, the loser's hosts get silently locked out.
18+
*
19+
* Contributors solve this:
20+
*
21+
* - Every feature that needs network access implements
22+
* [NetworkPolicyContributor] and Hilt-registers itself via
23+
* `@IntoSet`.
24+
* - When the feature is "off" (user toggle disabled, download finished),
25+
* the contributor emits [NetworkPolicyContribution.Inactive] —
26+
* contributing nothing to the union.
27+
* - When the feature is "on", it emits a
28+
* [NetworkPolicyContribution.Active] carrying its hosts + per-host limits.
29+
* - The aggregator unions every active contribution into a single
30+
* [NetworkPolicy] and arms the kill switch iff at least one contributor
31+
* is active.
32+
*
33+
* Direct [NetworkGateway.setEnabled] / [NetworkGateway.setPolicy] calls from
34+
* feature code are now an anti-pattern — they will be silently overwritten on
35+
* the next contributor emission.
36+
*
37+
* # Threading
38+
*
39+
* [contribution] is a [StateFlow]; the aggregator subscribes once at app
40+
* startup and stays subscribed for process lifetime. Implementations are
41+
* responsible for hoisting their preference / state flows onto a stable
42+
* [StateFlow] (typically via `stateIn(scope, SharingStarted.Eagerly, …)`)
43+
* so the aggregator always has a value to combine without suspending.
44+
*/
45+
interface NetworkPolicyContributor {
46+
/**
47+
* Stable identifier (e.g. `"map-tiles"`, `"osm-download"`). Used for
48+
* debugging, audit logs, and future per-contributor diagnostics. NOT
49+
* surfaced in UI — pick something machine-readable and stable across
50+
* releases.
51+
*/
52+
val id: String
53+
54+
/**
55+
* Reactive contribution. Each emission is this contributor's current
56+
* snapshot of "what hosts I need + with what rate limits". When this
57+
* contributor is off (e.g. user toggle disabled, download finished),
58+
* emit [NetworkPolicyContribution.Inactive].
59+
*
60+
* The aggregator re-derives the effective policy on every emission, so
61+
* implementations should debounce or coalesce upstream noise themselves
62+
* if relevant (preferences typically dedupe via `distinctUntilChanged`
63+
* already).
64+
*/
65+
val contribution: StateFlow<NetworkPolicyContribution>
66+
}
67+
68+
/**
69+
* One contributor's snapshot of "what should the gateway allow on my behalf
70+
* right now". Combined across every registered [NetworkPolicyContributor] by
71+
* [NetworkPolicyAggregator] into the effective [NetworkPolicy].
72+
*/
73+
sealed interface NetworkPolicyContribution {
74+
/**
75+
* This contributor is currently OFF — it contributes no hosts and does
76+
* not vote to arm the gateway. If every contributor is [Inactive] the
77+
* aggregator disables the kill switch and publishes [NetworkPolicy.EMPTY].
78+
*/
79+
data object Inactive : NetworkPolicyContribution
80+
81+
/**
82+
* This contributor is currently ON — the gateway must allow these hosts
83+
* and the kill switch must be armed.
84+
*
85+
* @property allowedHosts Exact hosts (lower-case, trimmed by
86+
* [NetworkPolicy]) this contributor requires. Subdomain wildcards are
87+
* NOT supported; list every concrete host.
88+
* @property perHostRateLimit Maximum requests per host per
89+
* [perHostRateWindowMs]. When multiple contributors are active the
90+
* aggregator currently takes the MAX (per the design note in the
91+
* coupling refactor) so a contributor with bursty needs (e.g. an
92+
* OSM PMTiles download) isn't bottlenecked by a quieter contributor
93+
* sharing the same effective policy. Per-host fine-grained limits
94+
* are a deferred extension to [NetworkPolicy].
95+
* @property perHostRateWindowMs The rate-limit window in milliseconds.
96+
* Defaults to [NetworkPolicy.DEFAULT_RATE_WINDOW_MS] (one minute).
97+
*/
98+
data class Active(
99+
val allowedHosts: Set<String>,
100+
val perHostRateLimit: Int = NetworkPolicy.DEFAULT_RATE_LIMIT,
101+
val perHostRateWindowMs: Long = NetworkPolicy.DEFAULT_RATE_WINDOW_MS,
102+
) : NetworkPolicyContribution
103+
}

0 commit comments

Comments
 (0)