Skip to content

[client] MDM Android mobile wiring#6435

Open
riccardomanfrin wants to merge 5 commits into
mainfrom
mdm_integration
Open

[client] MDM Android mobile wiring#6435
riccardomanfrin wants to merge 5 commits into
mainfrom
mdm_integration

Conversation

@riccardomanfrin

@riccardomanfrin riccardomanfrin commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Describe your changes

Adds the Android-side of the MDM bridge that lets the mobile gomobile
binding accept a managed-configuration snapshot from the native layer
(Kotlin) and surface it through the same Config.apply → applyMDMPolicy → loadMDMPolicy → loadPlatformPolicy chain the desktop
loaders already use.

Two files:

  • client/mdm/policy_mobile.go (replaces the previous (nil, nil)
    stub). Defines an internal PolicyFetcher interface and a
    SetMobilePolicyFetcher setter; the registered fetcher is what
    loadPlatformPolicy calls on every read. Set-once at gomobile
    init, never mutated at runtime → no synchronisation required for
    the read path.

  • client/android/mdm.go (new). The gomobile-facing bridge.
    Exposes a Java/Kotlin-friendly PolicyFetcher interface with a
    single FetchJSON() string method — map[string]any cannot
    cross the gomobile JNI boundary, so the native layer returns a
    JSON-encoded snapshot and a Go-side jsonFetcherAdapter parses it
    back into the internal mdm.PolicyFetcher. Adds
    Client.OnMDMPolicyChanged() as the entry point the native
    broadcast receiver invokes when the OS reports a managed-config
    change; the method cancels the current engine context so the
    outer native loop relaunches Run() and the new run picks up
    the fresh policy.

Runtime flow on a managed-config change (mobile):

  1. OS broadcasts ACTION_APPLICATION_RESTRICTIONS_CHANGED (Android)
    / UserDefaults.didChangeNotification (iOS).
  2. Native bridge calls Client.OnMDMPolicyChanged()Stop()
    engine teardown.
  3. Native loop relaunches Run().
  4. UpdateOrCreateConfigConfig.apply
    applyMDMPolicy(loadMDMPolicy())
    LoadPolicy → loadPlatformPolicy
    fetcher.Fetch() → adapter FetchJSON() callback into Kotlin
    RestrictionsManager.getApplicationRestrictions() parsed and
    returned to Go.
  5. Engine restarts with the fresh MDM overlay.

No ticker on mobile (the desktop ticker stays unchanged in
client/server, build-tagged !ios && !android at the call site
via service_controller.go). The OS notification is authoritative;
no Go-side diff loop, no cached policy snapshot in either Go or
Kotlin — RestrictionsManager is the single source of truth.

The Kotlin / Java side of the wiring (the BroadcastReceiver, the
fetcher implementation, the app_restrictions.xml schema, the
Settings UI lock-on-managed-keys, the EngineRestarter wiring) lives
in the netbirdio/android-client repo and is shipped in a separate
PR there.

e2e testing done on Android.

Issue ticket number and link

No public issue. Internal task to land the Android half of the MDM
managed-configuration feature originally shipped for desktop in PR
#6374. The mobile half was
called out in that PR's description as a follow-up. This PR is the
Go-side of that follow-up; the Kotlin side is a companion PR against
netbirdio/android-client.

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

The desktop MDM documentation in netbirdio/docs already describes
the managed-key set and the per-platform mappings (Windows registry,
macOS plist). The mobile path consumes the exact same key set, so no
new key documentation is needed. The Kotlin / Java integration of
the bridge — MDMPolicyFetcher, the BroadcastReceiver, the
app_restrictions.xml manifest entry — is shipped and documented in
the companion netbirdio/android-client PR; the Go-side public
surface added here (Client.OnMDMPolicyChanged,
SetMobilePolicyFetcher, PolicyFetcher) is implementation glue
that only the in-repo native bridge calls, not an API end users
configure directly.

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

N/A

Summary by CodeRabbit

Release Notes

  • New Features

    • Android can now ingest managed device policy updates from the mobile platform’s managed configuration data.
    • MDM policy loading is now dependency-injected, with daemon and config generation consistently applying the active MDM overlay (including periodic refresh).
  • Bug Fixes

    • Correct handling of “no policy source” vs an empty policy.
    • Malformed managed policy JSON is treated as empty (with a warning).
    • MDM overlays are applied safely to avoid stale or nil policy results.
  • Tests

    • Updated MDM tests to use per-instance loader-based injection instead of global overrides.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b302eb9-3aee-4714-b936-523e2be234ed

📥 Commits

Reviewing files that changed from the base of the PR and between 0340893 and b2c5732.

📒 Files selected for processing (3)
  • client/cmd/login.go
  • client/cmd/up.go
  • client/embed/embed.go

📝 Walkthrough

Walkthrough

Refactors MDM policy loading system across desktop, server, and Android from package-level global functions and test hooks to dependency-injected Loader instances. Replaces LoadPolicy() export with Loader type holding optional PolicyFetcher. Updates all platform implementations to delegate to injected fetchers. Adds ApplyMDMPolicy method for explicit config overlays. Introduces Android JSON adapter and per-client/server loader instances. Adds explicit overlay application to CLI/embedded paths. Updates all tests to construct loaders directly instead of mutating globals.

Changes

MDM Loader Dependency Injection Refactor

Layer / File(s) Summary
Core Loader abstraction and PolicyFetcher interface
client/mdm/policy.go, client/mdm/policy_test.go
Replaces the exported LoadPolicy() function with a dependency-injected Loader type, NewLoader constructor, and (*Loader).Load() method. Adds PolicyFetcher interface for mobile platforms. Nil-receiver Load() returns an empty non-nil Policy. Updates unit test to validate nil-fetcher loader behavior.
Platform-specific loadPlatform() implementations
client/mdm/policy_darwin.go, client/mdm/policy_mobile.go, client/mdm/policy_other.go, client/mdm/policy_windows.go
Converts all platform loader implementations from standalone loadPlatformPolicy() functions to (*Loader).loadPlatform() methods. Each method conditionally returns l.fetcher.Fetch() when available, otherwise delegates to platform logic (plist on macOS, registry on Windows, nil sentinel on mobile/other). Updates documentation to reflect the loader pattern.
Android MDM JSON adapter and PolicyFetcher registration
client/android/mdm.go
Declares Android-specific PolicyFetcher interface with JSON/empty/error semantics for Kotlin bridge. Implements jsonFetcherAdapter.Fetch() to unmarshal JSON strings to map[string]any with warning on malformed input. Adds (*Client) SetMDMPolicyFetcher(p PolicyFetcher) to register or disable per-client mdm.Loader.
Android client config overlay wiring
client/android/client.go
Adds mdmLoader *mdm.Loader field to Client. Applies MDM overlay via c.applyMDMOverlay(cfg) after config load in Run, RunWithoutLogin, and DebugBundle before engine initialization. Implements applyMDMOverlay helper that applies c.mdmLoader.Load() to config via ApplyMDMPolicy.
Config ApplyMDMPolicy API and initialization
client/internal/profilemanager/config.go
Removes package-level loadMDMPolicy indirection. Adds exported (*Config).ApplyMDMPolicy(*mdm.Policy) method for explicit overlay application. Updates (*Config).apply() to initialize overlay via mdm.NewPolicy(nil) instead of global hook, ensuring fresh empty policy at apply time.
Server-side daemon-owned MDM loader
client/server/server.go, client/server/mdm.go
Adds mdmLoader *mdm.Loader field to daemon Server. Lazily initializes loader in Start() and passes to mdmTicker. Updates SetConfig and Login handlers to use s.mdmLoader.Load() for conflict detection. In getConfig, explicitly applies overlay via config.ApplyMDMPolicy(s.mdmLoader.Load()) before returning. Applies overlay in GetConfig RPC and logout path. Removes global loadMDMPolicy variable from mdm.go.
MDM Ticker dependency injection
client/mdm/ticker.go, client/mdm/ticker_test.go
Updates Ticker to store injected *Loader. Modifies NewTicker to accept loader parameter and initialize prev via loader.Load(). Updates Run to load policy via t.loader.Load() on each tick. Refactors tests to use fakePolicyFetcher with mutex-protected state, construct loaders via NewLoader(fetcher), and defer goroutine cleanup to avoid races.
Explicit MDM overlay in CLI and embedded paths
client/cmd/login.go, client/cmd/up.go, client/embed/embed.go
Adds explicit MDM policy overlay application to CLI and embedded deployment paths. login.go and up.go apply config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) after config load in foreground mode. embed.go applies MDM policy to generated config immediately after creation before option overrides. Replaces implicit auto-apply behavior with explicit control.
Config test helper refactoring
client/internal/profilemanager/config_mdm_test.go
Introduces fakeFetcher, loaderFor, and configWithMDM helpers to construct loaders from policies and apply them to configs without global hook mutation. Updates all MDM config tests to use configWithMDM(...) with inline policy inputs instead of withMDMPolicy global overrides.
Server test helper refactoring
client/server/setconfig_mdm_test.go
Updates withMDMPolicy helper to accept s *Server and inject mdm.Loader directly onto server. Introduces fakeMDMFetcher for test policy injection. Refactors all MDM TestSetConfig_* cases to use per-server policy injection instead of global loadMDMPolicy mutation.

Sequence Diagram(s)

sequenceDiagram
  participant Kotlin as Kotlin/Native
  participant AndroidClient as Android Client
  participant MDMLoader as MDM Loader
  participant PlatformImpl as Platform Impl
  participant Config as Config

  Kotlin->>AndroidClient: SetMDMPolicyFetcher(fetcher)
  AndroidClient->>MDMLoader: NewLoader(jsonFetcherAdapter)
  Note over AndroidClient: Stores in mdmLoader field

  rect rgba(100, 150, 200, 0.5)
    Note over AndroidClient,Config: Config Load Path
    AndroidClient->>Config: UpdateOrCreateConfig()
    Config->>Config: apply() with NewPolicy(nil)
    AndroidClient->>MDMLoader: applyMDMOverlay(cfg)
    MDMLoader->>MDMLoader: Load()
    MDMLoader->>PlatformImpl: loadPlatform()
    PlatformImpl->>AndroidClient: fetcher.Fetch()
    AndroidClient->>Kotlin: FetchJSON()
    Kotlin-->>AndroidClient: JSON string
    AndroidClient-->>PlatformImpl: map[string]any
    PlatformImpl-->>MDMLoader: policy map
    MDMLoader->>Config: ApplyMDMPolicy(policy)
    Config-->>AndroidClient: config with overlay
  end
Loading
sequenceDiagram
  participant Server as Server.Start()
  participant MDMLoader as MDM Loader
  participant Ticker as MDM Ticker
  participant Fetcher as Platform Fetcher

  Server->>MDMLoader: NewLoader(nil)
  Note over Server: Stores in mdmLoader field

  Server->>Ticker: NewTicker(interval, mdmLoader)
  Ticker->>MDMLoader: Load() init prev
  MDMLoader->>Fetcher: loadPlatform()
  Fetcher-->>MDMLoader: policy map (or nil)
  MDMLoader-->>Ticker: Policy

  rect rgba(150, 200, 100, 0.5)
    Note over Ticker,Fetcher: Ticker Run Loop (periodic)
    loop Each Tick
      Ticker->>MDMLoader: Load() current
      MDMLoader->>Fetcher: loadPlatform()
      Fetcher-->>MDMLoader: updated policy
      MDMLoader-->>Ticker: Policy
      Ticker->>Ticker: Compare prev vs curr
      alt Policy Changed
        Ticker->>Ticker: Emit diffs, invoke onChange
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • netbirdio/netbird#6374: Refactors the same MDM policy pipeline in client/mdm/policy*.go and client/internal/profilemanager/config*.go, with direct integration changes to how MDM overlays are applied and tested.

Suggested reviewers

  • lixmal
  • pappz
  • mlsmaycon

Poem

🐇 Through nested layers of policy I bound,
Global functions replaced without a sound,
Each loader injected, each fetcher takes flight,
From JSON to registry, dependency done right! ✨
Android builds bridges, the server stands tall,
Explicit overlays now answer the call! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title '[client] MDM Android mobile wiring' accurately and concisely describes the main change: adding Android-specific MDM bridge functionality to the client.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the changes, issue context, rationale, and documentation status. It includes the required checklist with feature enhancement selected and discusses API changes with the team context. Documentation necessity is explained due to the internal nature of the Go-side public surface.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mdm_integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/mdm/policy_mobile.go`:
- Around line 20-41: The global fetcher variable accessed by
SetMobilePolicyFetcher and loadPlatformPolicy lacks synchronization, creating a
potential data race if SetMobilePolicyFetcher is called concurrently or after
initialization. Add a sync.Mutex to protect access to the fetcher variable. In
SetMobilePolicyFetcher, acquire the lock before writing to fetcher, and in
loadPlatformPolicy, acquire the lock before reading from fetcher. This ensures
atomic registration and read operations regardless of when
SetMobilePolicyFetcher is invoked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21ce5b76-f5ba-4cd4-b900-185874005e16

📥 Commits

Reviewing files that changed from the base of the PR and between 6006761 and db2c9b6.

📒 Files selected for processing (2)
  • client/android/mdm.go
  • client/mdm/policy_mobile.go

Comment thread client/mdm/policy_mobile.go Outdated
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown

Release artifacts

Built for PR head b2c5732 in workflow run #15890.

Artifact Link
All release artifacts Download
Linux packages Download
Windows packages Download
macOS packages Download
UI artifacts Download
UI macOS artifacts Download

GHCR images (amd64)

This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
client/mdm/policy.go (1)

108-111: ⚡ Quick win

Align NewLoader documentation with actual platform behavior.

Line 108–111 says the fetcher is consulted only on mobile, but desktop loaders also short-circuit to l.fetcher.Fetch() when a fetcher is injected. Please update this comment to match the real cross-platform contract and avoid misleading call sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/mdm/policy.go` around lines 108 - 111, The comment for NewLoader
function (lines 108-111) incorrectly states that the fetcher is consulted only
on mobile builds and is unused on desktop. Update this documentation comment to
accurately reflect that the fetcher is consulted on both mobile and desktop
platforms when a fetcher is injected, not just on mobile. The comment should
clarify the actual cross-platform contract to avoid misleading callers.
client/internal/profilemanager/config.go (1)

187-193: ⚡ Quick win

Clarify ApplyMDMPolicy reset semantics in the doc comment.

The current wording implies that an empty policy clears prior overlay effects on the same Config instance, but applyMDMPolicy is overlay-only and does not restore previously overridden fields. Please tighten the comment to reflect “apply on freshly resolved config” semantics (or implement explicit revert behavior).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/internal/profilemanager/config.go` around lines 187 - 193, The doc
comment for the ApplyMDMPolicy function currently states that passing an empty
Policy will clear any prior overlay, but this is misleading since the function
only applies overlays and does not restore previously overridden fields. Update
the doc comment to clarify the actual behavior: that ApplyMDMPolicy applies MDM
policy overlays on top of the currently resolved config values and should be
called on freshly resolved config (as the lifecycle owner does), rather than
implying it can be used to revert or clear prior overlays by passing an empty
policy. Remove or revise the wording about clearing prior overlays to accurately
reflect overlay-only semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/mdm/policy_test.go`:
- Around line 153-161: The test TestLoader_NilFetcherReturnsEmpty is
non-deterministic because calling NewLoader(nil).Load() may read real MDM state
on Windows/macOS, causing the emptiness assertions to fail on managed hosts.
Either add build tags (such as //go:build linux) to gate this test to platforms
where nil fetcher produces a deterministic empty result, or refactor the test to
use a mock fetcher that ensures deterministic behavior regardless of actual MDM
state on the host. This ensures the assertions in the test remain valid and
consistent across all platforms.

---

Nitpick comments:
In `@client/internal/profilemanager/config.go`:
- Around line 187-193: The doc comment for the ApplyMDMPolicy function currently
states that passing an empty Policy will clear any prior overlay, but this is
misleading since the function only applies overlays and does not restore
previously overridden fields. Update the doc comment to clarify the actual
behavior: that ApplyMDMPolicy applies MDM policy overlays on top of the
currently resolved config values and should be called on freshly resolved config
(as the lifecycle owner does), rather than implying it can be used to revert or
clear prior overlays by passing an empty policy. Remove or revise the wording
about clearing prior overlays to accurately reflect overlay-only semantics.

In `@client/mdm/policy.go`:
- Around line 108-111: The comment for NewLoader function (lines 108-111)
incorrectly states that the fetcher is consulted only on mobile builds and is
unused on desktop. Update this documentation comment to accurately reflect that
the fetcher is consulted on both mobile and desktop platforms when a fetcher is
injected, not just on mobile. The comment should clarify the actual
cross-platform contract to avoid misleading callers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 239e01a4-4e58-4765-ad8d-d153e6b35e88

📥 Commits

Reviewing files that changed from the base of the PR and between bec26d5 and 8741954.

📒 Files selected for processing (15)
  • client/android/client.go
  • client/android/mdm.go
  • client/internal/profilemanager/config.go
  • client/internal/profilemanager/config_mdm_test.go
  • client/mdm/policy.go
  • client/mdm/policy_darwin.go
  • client/mdm/policy_mobile.go
  • client/mdm/policy_other.go
  • client/mdm/policy_test.go
  • client/mdm/policy_windows.go
  • client/mdm/ticker.go
  • client/mdm/ticker_test.go
  • client/server/mdm.go
  • client/server/server.go
  • client/server/setconfig_mdm_test.go
💤 Files with no reviewable changes (1)
  • client/server/mdm.go

Comment thread client/mdm/policy_test.go
Comment on lines +153 to 161
func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
// Loader.Load with no fetcher (desktop construction) must degrade
// gracefully and never return nil; on linux loadPlatform is a stub
// returning (nil, nil), and Load is expected to translate that
// into a non-nil empty Policy.
p := NewLoader(nil).Load()
require.NotNil(t, p)
assert.True(t, p.IsEmpty())
assert.Empty(t, p.ManagedKeys())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make this test deterministic across platforms.

On Line 158, NewLoader(nil).Load() can read real MDM state on Windows/macOS, so the emptiness assertion on Line 160 may fail on managed hosts. Please gate this test to platforms where nil fetcher means “no source,” or switch the test to a deterministic seam.

One stabilization option
+import "runtime"
+
 func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
+	if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+		t.Skip("nil fetcher may read real OS-managed policy on this platform")
+	}
 	p := NewLoader(nil).Load()
 	require.NotNil(t, p)
 	assert.True(t, p.IsEmpty())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/mdm/policy_test.go` around lines 153 - 161, The test
TestLoader_NilFetcherReturnsEmpty is non-deterministic because calling
NewLoader(nil).Load() may read real MDM state on Windows/macOS, causing the
emptiness assertions to fail on managed hosts. Either add build tags (such as
//go:build linux) to gate this test to platforms where nil fetcher produces a
deterministic empty result, or refactor the test to use a mock fetcher that
ensures deterministic behavior regardless of actual MDM state on the host. This
ensures the assertions in the test remain valid and consistent across all
platforms.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant