react-native-intune 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +27 -0
  2. package/NOTICE +49 -0
  3. package/README.md +164 -0
  4. package/RNIntune.podspec +69 -0
  5. package/android/build.gradle +152 -0
  6. package/android/consumer-rules.pro +28 -0
  7. package/android/src/main/AndroidManifest.xml +2 -0
  8. package/android/src/main/java/com/reactnativeintune/RNIntuneAuth.kt +330 -0
  9. package/android/src/main/java/com/reactnativeintune/RNIntuneAuthCallback.kt +195 -0
  10. package/android/src/main/java/com/reactnativeintune/RNIntuneConfig.kt +63 -0
  11. package/android/src/main/java/com/reactnativeintune/RNIntuneEventQueue.kt +50 -0
  12. package/android/src/main/java/com/reactnativeintune/RNIntuneModule.kt +752 -0
  13. package/android/src/main/java/com/reactnativeintune/RNIntuneMsalConfig.kt +78 -0
  14. package/android/src/main/java/com/reactnativeintune/RNIntuneNotifications.kt +187 -0
  15. package/android/src/main/java/com/reactnativeintune/RNIntunePackage.kt +33 -0
  16. package/android/src/main/java/com/reactnativeintune/RNIntunePolicy.kt +108 -0
  17. package/android/src/main/java/com/reactnativeintune/RNIntuneReset.kt +85 -0
  18. package/android/src/main/java/com/reactnativeintune/RNIntuneResetJournal.kt +140 -0
  19. package/app.plugin.js +10 -0
  20. package/ios/RNIntune.h +20 -0
  21. package/ios/RNIntune.mm +467 -0
  22. package/ios/RNIntuneAuth.h +121 -0
  23. package/ios/RNIntuneAuth.m +390 -0
  24. package/ios/RNIntuneCore.h +150 -0
  25. package/ios/RNIntuneCore.m +535 -0
  26. package/ios/RNIntuneDelegates.h +70 -0
  27. package/ios/RNIntuneDelegates.m +268 -0
  28. package/ios/RNIntunePendingRequests.h +57 -0
  29. package/ios/RNIntunePendingRequests.m +230 -0
  30. package/ios/RNIntunePlistGuard.h +37 -0
  31. package/ios/RNIntunePlistGuard.m +147 -0
  32. package/ios/RNIntunePolicy.h +25 -0
  33. package/ios/RNIntunePolicy.m +112 -0
  34. package/ios/RNIntuneReset.h +34 -0
  35. package/ios/RNIntuneReset.m +93 -0
  36. package/ios/RNIntuneResetJournal.h +69 -0
  37. package/ios/RNIntuneResetJournal.m +106 -0
  38. package/lib/module/NativeIntune.js +20 -0
  39. package/lib/module/NativeIntune.js.map +1 -0
  40. package/lib/module/api/auth.js +66 -0
  41. package/lib/module/api/auth.js.map +1 -0
  42. package/lib/module/api/enrollment.js +104 -0
  43. package/lib/module/api/enrollment.js.map +1 -0
  44. package/lib/module/api/events.js +50 -0
  45. package/lib/module/api/events.js.map +1 -0
  46. package/lib/module/api/lifecycle.js +73 -0
  47. package/lib/module/api/lifecycle.js.map +1 -0
  48. package/lib/module/api/policy.js +23 -0
  49. package/lib/module/api/policy.js.map +1 -0
  50. package/lib/module/api/tokenProvider.js +80 -0
  51. package/lib/module/api/tokenProvider.js.map +1 -0
  52. package/lib/module/index.js +82 -0
  53. package/lib/module/index.js.map +1 -0
  54. package/lib/module/internal/decode.js +153 -0
  55. package/lib/module/internal/decode.js.map +1 -0
  56. package/lib/module/internal/emitter.js +15 -0
  57. package/lib/module/internal/emitter.js.map +1 -0
  58. package/lib/module/internal/errors.js +32 -0
  59. package/lib/module/internal/errors.js.map +1 -0
  60. package/lib/module/package.json +1 -0
  61. package/lib/module/types.js +143 -0
  62. package/lib/module/types.js.map +1 -0
  63. package/lib/typescript/package.json +1 -0
  64. package/lib/typescript/src/NativeIntune.d.ts +120 -0
  65. package/lib/typescript/src/NativeIntune.d.ts.map +1 -0
  66. package/lib/typescript/src/api/auth.d.ts +37 -0
  67. package/lib/typescript/src/api/auth.d.ts.map +1 -0
  68. package/lib/typescript/src/api/enrollment.d.ts +54 -0
  69. package/lib/typescript/src/api/enrollment.d.ts.map +1 -0
  70. package/lib/typescript/src/api/events.d.ts +24 -0
  71. package/lib/typescript/src/api/events.d.ts.map +1 -0
  72. package/lib/typescript/src/api/lifecycle.d.ts +33 -0
  73. package/lib/typescript/src/api/lifecycle.d.ts.map +1 -0
  74. package/lib/typescript/src/api/policy.d.ts +14 -0
  75. package/lib/typescript/src/api/policy.d.ts.map +1 -0
  76. package/lib/typescript/src/api/tokenProvider.d.ts +18 -0
  77. package/lib/typescript/src/api/tokenProvider.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +67 -0
  79. package/lib/typescript/src/index.d.ts.map +1 -0
  80. package/lib/typescript/src/internal/decode.d.ts +48 -0
  81. package/lib/typescript/src/internal/decode.d.ts.map +1 -0
  82. package/lib/typescript/src/internal/emitter.d.ts +11 -0
  83. package/lib/typescript/src/internal/emitter.d.ts.map +1 -0
  84. package/lib/typescript/src/internal/errors.d.ts +22 -0
  85. package/lib/typescript/src/internal/errors.d.ts.map +1 -0
  86. package/lib/typescript/src/types.d.ts +317 -0
  87. package/lib/typescript/src/types.d.ts.map +1 -0
  88. package/package.json +204 -0
  89. package/plugin/transforms.js +153 -0
  90. package/plugin/withIntune.js +320 -0
  91. package/scripts/cli.mjs +72 -0
  92. package/scripts/doctor.mjs +232 -0
  93. package/scripts/fetch-sdks.mjs +456 -0
  94. package/scripts/lib/checks.mjs +720 -0
  95. package/scripts/lib/project.mjs +196 -0
  96. package/scripts/setup.mjs +233 -0
  97. package/sdk-lock.json +27 -0
  98. package/sdk-versions.json +70 -0
  99. package/src/NativeIntune.ts +130 -0
  100. package/src/api/auth.ts +73 -0
  101. package/src/api/enrollment.ts +112 -0
  102. package/src/api/events.ts +87 -0
  103. package/src/api/lifecycle.ts +75 -0
  104. package/src/api/policy.ts +22 -0
  105. package/src/api/tokenProvider.ts +80 -0
  106. package/src/index.ts +127 -0
  107. package/src/internal/decode.ts +191 -0
  108. package/src/internal/emitter.ts +14 -0
  109. package/src/internal/errors.ts +43 -0
  110. package/src/types.ts +381 -0
@@ -0,0 +1,752 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.WritableMap
8
+ import android.content.Intent
9
+ import android.net.Uri
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.util.Log
13
+ import com.microsoft.intune.mam.client.app.MAMComponents
14
+ import com.microsoft.intune.mam.client.strict.MAMStrictMode
15
+ import com.microsoft.intune.mam.policy.MAMEnrollmentManager
16
+ import com.microsoft.intune.mam.policy.MAMUserInfo
17
+
18
+ /**
19
+ * TurboModule implementation. `NativeIntuneSpec` is generated by Codegen from
20
+ * `src/NativeIntune.ts` into this same package (`codegenConfig.android.javaPackageName`),
21
+ * so there is nothing to import.
22
+ *
23
+ * Every spec parameter arrives as a [ReadableMap] because Codegen maps inline object
24
+ * types that way on Android — unlike iOS, where they become typed C++ structs. Read keys
25
+ * through the constants in [Keys] rather than inline strings: a typo in a key is silent.
26
+ */
27
+ class RNIntuneModule(private val reactContext: ReactApplicationContext) :
28
+ NativeIntuneSpec(reactContext) {
29
+
30
+ private val events = RNIntuneEventQueue(reactContext)
31
+
32
+ private var config: RNIntuneConfig? = null
33
+
34
+ /** MSAL. Built at configure time in `builtin` mode only (SPEC §3.2). */
35
+ private val auth = RNIntuneAuth(reactContext)
36
+
37
+ /** Held so they can be unregistered; also proves the plugin processed the app. */
38
+ private var registrations: List<RNIntuneNotifications.Registration> = emptyList()
39
+
40
+ /**
41
+ * The account this process registered. Android cannot enumerate registered accounts,
42
+ * so there is nothing to read it back from after a restart.
43
+ */
44
+ private var registeredAccountId: String? = null
45
+
46
+ /** The UPN the account was registered with. `unregisterAccountForMAM` needs it too. */
47
+ private var registeredUpn: String? = null
48
+
49
+ /** accountId -> completion, settled from the notification receiver. */
50
+ private val pendingEnrollments = mutableMapOf<String, (WritableMap) -> Unit>()
51
+
52
+ private val timeoutHandler = Handler(Looper.getMainLooper())
53
+
54
+ // ------------------------------------------------------------------ lifecycle
55
+
56
+ init {
57
+ // The callback object outlives any single React context — it is registered from
58
+ // Application.onMAMCreate — so it is pointed at whichever module instance is live.
59
+ RNIntuneAuthCallback.attach(this)
60
+ }
61
+
62
+ override fun invalidate() {
63
+ RNIntuneAuthCallback.detach(this)
64
+ timeoutHandler.removeCallbacksAndMessages(null)
65
+ RNIntuneNotifications.unregister(registrations)
66
+ registrations = emptyList()
67
+ super.invalidate()
68
+ }
69
+
70
+ override fun configure(config: ReadableMap, promise: Promise) {
71
+ if (!sdkAvailable) {
72
+ promise.reject(
73
+ ERR_SDK_UNAVAILABLE,
74
+ "The Intune MAM SDK is not on the classpath. Check that vendor/android was " +
75
+ "fetched and that the MAM Gradle plugin is applied in the app module.",
76
+ )
77
+ return
78
+ }
79
+
80
+ val incoming = RNIntuneConfig.from(config)
81
+
82
+ // Switching tenants requires an explicit reset: reconfiguring in place would leave
83
+ // the previous tenant registered (SPEC §13.1).
84
+ val active = this.config
85
+ if (active != null && active.tenantId != incoming.tenantId) {
86
+ promise.reject(
87
+ ERR_RESET_REQUIRED,
88
+ "configure() was called with a different tenantId than the active one. " +
89
+ "Call reset() before switching tenants.",
90
+ )
91
+ return
92
+ }
93
+
94
+ // No branding here. The three iOS runtime colour properties have no Android
95
+ // counterpart — the SDK's own screens follow the app's theme instead — so the
96
+ // branding fields are silently unused on this platform rather than faked.
97
+
98
+ // Strict Mode throws on integration bugs that would otherwise silently leave
99
+ // corporate data unprotected. It is not meant for production, which is why the
100
+ // public default is __DEV__ and not `true` (SPEC §6.1).
101
+ if (incoming.strictMode) {
102
+ runCatching { MAMStrictMode.enable() }
103
+ .onFailure { Log.w(TAG, "MAM Strict Mode could not be enabled: ${it.message}") }
104
+ }
105
+
106
+ // Registered once and kept for the process lifetime: the SDK notifies unprompted,
107
+ // including on its own enrollment retry schedule and for service-initiated wipes.
108
+ if (registrations.isEmpty()) {
109
+ registrations = RNIntuneNotifications.register(::emit)
110
+ if (registrations.isEmpty()) {
111
+ // MAMComponents returns null when the app was not processed by the MAM Gradle
112
+ // plugin. Everything would then appear to work while nothing was protected, so
113
+ // this is the one integration mistake worth refusing to start on.
114
+ promise.reject(
115
+ ERR_SDK_UNAVAILABLE,
116
+ "The MAM notification registry is unavailable. This app was almost certainly " +
117
+ "built without the MAM Gradle plugin applied to its app module — see the " +
118
+ "README integration checklist. Without it the SDK is present but inert.",
119
+ )
120
+ return
121
+ }
122
+ }
123
+
124
+ this.config = incoming
125
+
126
+ // Unlike iOS there is no runtime override to set: these values are held here and
127
+ // passed per-call to registerAccountForMAM (SPEC §13.1). The MAM service auth
128
+ // callback is not registered here either — it has to be in place from
129
+ // Application.onMAMCreate, long before this method can run, so it is the consumer's
130
+ // wiring and we only supply the class (SPEC §6.1.2).
131
+
132
+ if (!incoming.builtinAuth) {
133
+ // `external`: the host app owns MSAL. Building a second client here would put two
134
+ // caches in one binary, which is the thing that mode exists to avoid (SPEC §3.2).
135
+ promise.resolve(null)
136
+ return
137
+ }
138
+
139
+ // Off the main thread: creating the MSAL client writes the config file and does
140
+ // network setup, and the blocking factory is the one that reports failure usefully.
141
+ Thread {
142
+ try {
143
+ auth.configure(incoming.clientId, incoming.authority, incoming.redirectUri)
144
+ promise.resolve(null)
145
+ } catch (e: Throwable) {
146
+ // Loud here rather than at the first sign-in, where the cause is much harder to
147
+ // see. A redirect URI whose signature hash does not match the registration is
148
+ // the common one.
149
+ this.config = null
150
+ promise.reject(
151
+ ERR_NATIVE,
152
+ "MSAL could not be initialised: ${e.message ?: "unknown reason"}",
153
+ )
154
+ }
155
+ }.start()
156
+ }
157
+
158
+ override fun isSupported(promise: Promise) {
159
+ promise.resolve(sdkAvailable)
160
+ }
161
+
162
+ override fun getBrokerStatus(promise: Promise) {
163
+ val companyPortal = isCompanyPortalInstalled()
164
+
165
+ promise.resolve(
166
+ Arguments.createMap().apply {
167
+ putBoolean("brokerAvailable", companyPortal)
168
+ putBoolean("companyPortalInstalled", companyPortal)
169
+ // Authenticator is not a broker for MAM on Android.
170
+ putBoolean("authenticatorInstalled", false)
171
+ // Company Portal is mandatory here — enrollment is impossible without it.
172
+ putBoolean("required", true)
173
+ }
174
+ )
175
+ }
176
+
177
+ override fun openBrokerInstall(promise: Promise) {
178
+ if (isPackageInstalled(COMPANY_PORTAL_PACKAGE)) {
179
+ promise.reject(ERR_NOT_NEEDED, "Company Portal is already installed.")
180
+ return
181
+ }
182
+
183
+ // market:// with the verified package name, falling back to the web Play Store for
184
+ // devices without the Play app.
185
+ val intents =
186
+ listOf(
187
+ Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$COMPANY_PORTAL_PACKAGE")),
188
+ Intent(
189
+ Intent.ACTION_VIEW,
190
+ Uri.parse("https://play.google.com/store/apps/details?id=$COMPANY_PORTAL_PACKAGE"),
191
+ ),
192
+ )
193
+
194
+ for (intent in intents) {
195
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
196
+ if (runCatching { reactContext.startActivity(intent) }.isSuccess) {
197
+ promise.resolve(null)
198
+ return
199
+ }
200
+ }
201
+ promise.reject(ERR_NATIVE, "Could not open the Play Store for Company Portal.")
202
+ }
203
+
204
+ // ------------------------------------------------------------------ auth
205
+
206
+ /** Returns false and rejects when the caller may not use the built-in MSAL. */
207
+ private fun authReady(method: String, promise: Promise): Boolean {
208
+ if (config == null) {
209
+ notConfigured(method, promise)
210
+ return false
211
+ }
212
+ if (config?.builtinAuth != true) {
213
+ promise.reject(
214
+ ERR_EXTERNAL_AUTH_MODE,
215
+ "$method is unavailable in authMode 'external'. The host app owns MSAL there; " +
216
+ "supply the account id to enroll() and answer tokenRequest via " +
217
+ "setTokenProvider.",
218
+ )
219
+ return false
220
+ }
221
+ return true
222
+ }
223
+
224
+ private fun scopesOf(params: ReadableMap): List<String> {
225
+ val array = runCatching { params.getArray("scopes") }.getOrNull() ?: return emptyList()
226
+ return (0 until array.size()).mapNotNull { array.getString(it) }
227
+ }
228
+
229
+ override fun signIn(params: ReadableMap, promise: Promise) {
230
+ if (!authReady("signIn", promise)) return
231
+ val scopes = scopesOf(params)
232
+ val loginHint = params.getString("loginHint")
233
+ val prompt = params.getString("prompt")
234
+
235
+ // Off the JS thread: with an account already signed in this tries the cache first,
236
+ // and that call blocks. MSAL puts its own UI on the main thread itself.
237
+ Thread {
238
+ auth.signIn(
239
+ scopes = scopes,
240
+ loginHint = loginHint,
241
+ prompt = prompt,
242
+ onResult = { promise.resolve(it) },
243
+ onError = { code, message -> promise.reject(code, message) },
244
+ )
245
+ }.start()
246
+ }
247
+
248
+ override fun signInSilent(params: ReadableMap, promise: Promise) {
249
+ if (!authReady("signInSilent", promise)) return
250
+ Thread {
251
+ auth.acquireTokenSilent(
252
+ scopes = scopesOf(params),
253
+ forceRefresh = false,
254
+ onResult = { promise.resolve(it) },
255
+ onError = { code, message -> promise.reject(code, message) },
256
+ )
257
+ }.start()
258
+ }
259
+
260
+ override fun acquireToken(params: ReadableMap, promise: Promise) {
261
+ if (!authReady("acquireToken", promise)) return
262
+ val forceRefresh =
263
+ runCatching { params.getBoolean("forceRefresh") }.getOrDefault(false)
264
+ Thread {
265
+ auth.acquireTokenSilent(
266
+ scopes = scopesOf(params),
267
+ forceRefresh = forceRefresh,
268
+ onResult = { promise.resolve(it) },
269
+ onError = { code, message -> promise.reject(code, message) },
270
+ )
271
+ }.start()
272
+ }
273
+
274
+ override fun getAccounts(promise: Promise) {
275
+ if (!authReady("getAccounts", promise)) return
276
+ Thread {
277
+ val out = Arguments.createArray()
278
+ auth.accounts().forEach { out.pushMap(it) }
279
+ promise.resolve(out)
280
+ }.start()
281
+ }
282
+
283
+ override fun signOut(params: ReadableMap, promise: Promise) {
284
+ if (!authReady("signOut", promise)) return
285
+ val wipeIntune = runCatching { params.getBoolean("wipeIntune") }.getOrDefault(false)
286
+
287
+ // Off the main thread: with a wipe this runs the whole reset sequence, whose
288
+ // unregister blocks (SPEC §5.3, §12.5).
289
+ Thread {
290
+ if (wipeIntune) {
291
+ // Order matters and is the reason this lives in one place: unregister first,
292
+ // then clear MSAL. Reversed, the unregister loses the token it needs.
293
+ doReset(wipe = true, reason = "logout")
294
+ } else {
295
+ auth.signOutQuietly()
296
+ }
297
+ promise.resolve(null)
298
+ }.start()
299
+ }
300
+
301
+ // ------------------------------------------------------------------ enrollment
302
+
303
+ override fun enroll(params: ReadableMap, promise: Promise) {
304
+ val config = this.config
305
+ if (config == null) {
306
+ notConfigured("enroll", promise)
307
+ return
308
+ }
309
+
310
+ val accountId = params.getString("accountId").orEmpty()
311
+ if (accountId.isEmpty()) {
312
+ promise.reject(ERR_INVALID_ACCOUNT_ID, "enroll() requires an Entra object ID.")
313
+ return
314
+ }
315
+
316
+ // registerAccountForMAM(upn, aadId, tenantId, authority) — parameter order verified
317
+ // from bytecode, not documentation: the offline implementation forwards them to
318
+ // MAMIdentityManager.insertOrUpdate(aadId, upn, tenantId, authority), which in turn
319
+ // builds MAMIdentity(upn, aadId, authority, tenantId).
320
+ //
321
+ // The UPN is required and NonNull, and this module's public API deliberately takes
322
+ // only the Entra object ID (SPEC §13.2). Nothing can supply the UPN yet: in
323
+ // authMode 'builtin' it comes from the module's own MSAL sign-in, which is spike
324
+ // S-3. Rejecting here with a named code beats calling the SDK with a fabricated
325
+ // identity, which would register the wrong account.
326
+ val upn = params.getString("upn").orEmpty().ifEmpty { knownUpnFor(accountId) }
327
+ if (upn.isNullOrEmpty()) {
328
+ promise.reject(
329
+ ERR_UPN_REQUIRED,
330
+ "Android enrollment needs the account's UPN as well as its object ID — " +
331
+ "registerAccountForMAM takes both and will not accept null for the UPN. In " +
332
+ "authMode 'builtin' the module reads it from its own MSAL account (spike " +
333
+ "S-3, not yet landed); in 'external' pass it as enroll({ accountId, upn }). " +
334
+ "iOS is unaffected: registerAndEnrollAccountId takes the object ID alone and " +
335
+ "the SDK resolves the UPN itself.",
336
+ )
337
+ return
338
+ }
339
+
340
+ val manager = MAMComponents.get(MAMEnrollmentManager::class.java)
341
+ if (manager == null) {
342
+ promise.reject(
343
+ ERR_SDK_UNAVAILABLE,
344
+ "MAMEnrollmentManager is unavailable — the MAM Gradle plugin is almost " +
345
+ "certainly not applied to this app module.",
346
+ )
347
+ return
348
+ }
349
+
350
+ // Already registered? Answer now.
351
+ //
352
+ // `registerAccountForMAM` for a known account is a no-op that fires **no**
353
+ // notification — the SDK logs "skipping already registered account" and returns. The
354
+ // promise below would then wait for a result that is never coming and settle as
355
+ // `pending` at the timeout, which is both slow and untrue. Observed on device.
356
+ val known = runCatching { manager.getRegisteredAccountStatus(upn, accountId) }.getOrNull()
357
+ if (known != null) {
358
+ registeredAccountId = accountId
359
+ registeredUpn = upn
360
+ promise.resolve(RNIntuneNotifications.resultFrom(known, accountId))
361
+ return
362
+ }
363
+
364
+ // Held against the account ID and settled from the notification receiver. The call
365
+ // below returns immediately and says nothing about the outcome (SPEC §12.5).
366
+ synchronized(pendingEnrollments) {
367
+ val existing = pendingEnrollments[accountId]
368
+ pendingEnrollments[accountId] = { result ->
369
+ existing?.invoke(result)
370
+ promise.resolve(result)
371
+ }
372
+ }
373
+
374
+ scheduleEnrollTimeout(accountId)
375
+
376
+ registeredAccountId = accountId
377
+ // Kept because unregisterAccountForMAM needs the UPN too, and by then the process
378
+ // may have restarted — so it also goes into the journal (SPEC §7).
379
+ registeredUpn = upn
380
+ manager.registerAccountForMAM(upn, accountId, config.tenantId, config.authority)
381
+ }
382
+
383
+ /**
384
+ * The UPN for an object ID, from the module's own MSAL account cache.
385
+ *
386
+ * Always null today — it becomes the MSAL lookup in the auth slice (S-3). Keeping the
387
+ * seam here means `enroll` does not change shape when that lands, and it is the reason
388
+ * the `upn` parameter is documented as `external`-mode only rather than as the normal
389
+ * way to call this.
390
+ */
391
+ /**
392
+ * The UPN for an account id, from the module's own MSAL sign-in.
393
+ *
394
+ * `registerAccountForMAM` needs both and will not take a null UPN, while the public API
395
+ * deliberately carries only the object ID (SPEC §6.3). In `external` mode there is no
396
+ * MSAL here to ask, and the caller passes `upn` explicitly.
397
+ */
398
+ private fun knownUpnFor(accountId: String): String? =
399
+ if (config?.builtinAuth == true) auth.upnFor(accountId) else null
400
+
401
+ private fun scheduleEnrollTimeout(accountId: String) {
402
+ timeoutHandler.postDelayed({
403
+ // Truthful rather than convenient: the SDK has not failed, it has not answered.
404
+ // It keeps retrying on its own schedule and the result still arrives as an event.
405
+ settleEnrollment(
406
+ accountId,
407
+ Arguments.createMap().apply {
408
+ putString("status", "pending")
409
+ putString("accountId", accountId)
410
+ putString("nativeCode", "RNIntuneTimeout")
411
+ putString(
412
+ "nativeMessage",
413
+ "The SDK did not report a result within the timeout. Enrollment continues " +
414
+ "in the background; watch onEnrollmentResult.",
415
+ )
416
+ putBoolean("restartRequired", false)
417
+ },
418
+ )
419
+ }, ENROLL_TIMEOUT_MS)
420
+ }
421
+
422
+ private fun settleEnrollment(accountId: String, result: WritableMap) {
423
+ val completion = synchronized(pendingEnrollments) { pendingEnrollments.remove(accountId) }
424
+ completion?.invoke(result)
425
+ }
426
+
427
+ /**
428
+ * iOS-only. `loginAndEnrollAccount:` has no Android counterpart —
429
+ * `registerAccountForMAM` always needs a token supplied by the app, so there is
430
+ * nothing here that can run a sign-in on the SDK's behalf.
431
+ */
432
+ override fun enrollInteractive(params: ReadableMap, promise: Promise) {
433
+ promise.reject(
434
+ ERR_SDK_UNAVAILABLE,
435
+ "enrollInteractive() is iOS-only. On Android the SDK cannot run the sign-in " +
436
+ "itself: registerAccountForMAM needs a token from the app, so use enroll() with " +
437
+ "a token provider.",
438
+ )
439
+ }
440
+
441
+ override fun getState(promise: Promise) {
442
+ // Deliberately never rejects: reporting `configured: false` is what makes this the
443
+ // reconciliation primitive (SPEC §13.2).
444
+ //
445
+ // Android has no "list registered accounts" API — getRegisteredAccountStatus takes
446
+ // an account ID and answers for that one. So the registered set is whatever this
447
+ // process registered, and after a restart it is empty until the app reconciles.
448
+ // That asymmetry with iOS (which has registeredAccountIds) is why the host app
449
+ // compares against its own backend rather than trusting this list alone.
450
+ val registered = registeredAccountId
451
+ val status = registered?.let { readStatus(it) }
452
+
453
+ promise.resolve(
454
+ Arguments.createMap().apply {
455
+ putBoolean("configured", config != null)
456
+ putString("configuredTenantId", config?.tenantId)
457
+ putArray(
458
+ "registeredAccountIds",
459
+ Arguments.createArray().apply { registered?.let { pushString(it) } },
460
+ )
461
+ putString(
462
+ "enrolledAccountId",
463
+ if (RNIntuneNotifications.unifiedStatus(status) == "succeeded") registered else null,
464
+ )
465
+ putString("status", status?.let { RNIntuneNotifications.unifiedStatus(it) })
466
+ putString("pendingReset", RNIntuneResetJournal.stage(reactContext))
467
+ putString("pendingResetReason", RNIntuneResetJournal.read(reactContext)?.reason)
468
+ }
469
+ )
470
+ }
471
+
472
+ /**
473
+ * **The process is expected to terminate during this call** (SPEC §7). Everything
474
+ * after `unregisterAccountForMAM` is best-effort; the journal is what carries the
475
+ * sequence across the death.
476
+ */
477
+ override fun reset(params: ReadableMap, promise: Promise) {
478
+ doReset(params.getBoolean("wipe"), params.getString("reason").orEmpty())
479
+ promise.resolve(null)
480
+ }
481
+
482
+ /** Shared by `reset` and `signOut({ wipeIntune: true })`, so the order cannot diverge. */
483
+ private fun doReset(wipe: Boolean, reason: String) {
484
+ RNIntuneReset.run(
485
+ context = reactContext,
486
+ accountId = registeredAccountId,
487
+ upn = registeredUpn,
488
+ tenantId = config?.tenantId,
489
+ wipe = wipe,
490
+ reason = reason,
491
+ )
492
+
493
+ // MSAL cache cleanup, after the unregister above and never before it: that call needs
494
+ // an Intune token which comes from this cache, so clearing it first would strand a
495
+ // registered account (SPEC §7 step 4). Skipped in `external`, where the cache is the
496
+ // host app's.
497
+ if (config?.builtinAuth == true) {
498
+ auth.signOutQuietly()
499
+ auth.invalidate()
500
+ }
501
+
502
+ config = null
503
+ registeredAccountId = null
504
+ registeredUpn = null
505
+ }
506
+
507
+ override fun completeReset(promise: Promise) {
508
+ // Verify rather than assume (SPEC §7 step 5). A reset that did not take stays open
509
+ // and is retried: the SDK resumes enrollment retries on its own schedule, and a
510
+ // half-finished reset is worse than none.
511
+ if (RNIntuneReset.complete(reactContext, ::readStatus)) {
512
+ promise.resolve(null)
513
+ return
514
+ }
515
+ promise.reject(
516
+ ERR_RESET_IN_PROGRESS,
517
+ "The account is still registered after reset, so the journal was left open. " +
518
+ "It will be retried on the next launch.",
519
+ )
520
+ }
521
+
522
+ // ------------------------------------------------------------------ policy
523
+
524
+ override fun getPolicy(promise: Promise) {
525
+ if (!sdkAvailable) {
526
+ promise.reject(ERR_SDK_UNAVAILABLE, "The Intune MAM SDK is not on the classpath.")
527
+ return
528
+ }
529
+ promise.resolve(RNIntunePolicy.snapshot(reactContext, registeredAccountId))
530
+ }
531
+
532
+ override fun getDiagnostics(promise: Promise) {
533
+ // Account IDs may appear here but must never be logged or persisted (CLAUDE.md
534
+ // rule 3). Nothing below is a token or a UPN.
535
+ promise.resolve(
536
+ Arguments.createMap().apply {
537
+ putString("platform", "android")
538
+ putString("sdkLinked", sdkAvailable.toString())
539
+ putString("configured", (config != null).toString())
540
+ putString("configuredTenantId", config?.tenantId.orEmpty())
541
+ putString("authMode", config?.authMode.orEmpty())
542
+ putString("companyPortalInstalled", isPackageInstalled(COMPANY_PORTAL_PACKAGE).toString())
543
+ putString("strictMode", (config?.strictMode ?: false).toString())
544
+ putString("notificationReceiversRegistered", registrations.size.toString())
545
+ if (sdkAvailable) {
546
+ // MAMComponents answers these without an enrolled account, and neither is a
547
+ // token, a UPN or an account ID (CLAUDE.md rule 3).
548
+ putString(
549
+ "managedApp",
550
+ runCatching { MAMComponents.isManagedApp(reactContext).toString() }
551
+ .getOrDefault("unknown"),
552
+ )
553
+ putString(
554
+ "wipeInProgress",
555
+ runCatching { MAMComponents.isWipeInProgress(reactContext).toString() }
556
+ .getOrDefault("unknown"),
557
+ )
558
+ }
559
+ }
560
+ )
561
+ }
562
+
563
+ // ------------------------------------------------------------------ token provider
564
+
565
+ /**
566
+ * Not public API — `setTokenProvider` in `index.ts` wraps these. The MAM service token
567
+ * never crosses back out to JS and is never logged (CLAUDE.md rule 9).
568
+ *
569
+ * Wiring lands with the auth callback (SPEC §13.4): the SDK asks on a background
570
+ * thread, including on its own retry schedule with no JS call in flight, so the
571
+ * request has to be correlated by `requestId` rather than awaited inline.
572
+ */
573
+ override fun resolveToken(params: ReadableMap) {
574
+ val requestId = params.getString("requestId") ?: return
575
+ RNIntuneAuthCallback.resolve(requestId, params.getString("token").orEmpty())
576
+ }
577
+
578
+ override fun rejectToken(params: ReadableMap) {
579
+ val requestId = params.getString("requestId") ?: return
580
+ RNIntuneAuthCallback.reject(requestId, params.getString("reason").orEmpty())
581
+ }
582
+
583
+ // ------------------------------------------------------------------ events
584
+
585
+ override fun addListener(eventName: String) = events.addListener()
586
+
587
+ override fun removeListeners(count: Double) = events.removeListeners(count.toInt())
588
+
589
+ /**
590
+ * A MAM service token from the module's own MSAL, or null when this is not our job.
591
+ *
592
+ * Null in `external` mode, where the host app owns MSAL and answers the JS
593
+ * `tokenRequest` instead. Called on the SDK's background thread and blocks it.
594
+ */
595
+ internal fun mamServiceToken(resourceId: String): String? =
596
+ if (config?.builtinAuth == true) auth.mamServiceToken(resourceId) else null
597
+
598
+ /** Called by the auth callback from the SDK's own background thread. */
599
+ internal fun emitTokenRequest(
600
+ requestId: String,
601
+ resourceId: String,
602
+ accountId: String,
603
+ tenantId: String,
604
+ authority: String,
605
+ ) {
606
+ emit(
607
+ "tokenRequest",
608
+ Arguments.createMap().apply {
609
+ putString("requestId", requestId)
610
+ putString("resourceId", resourceId)
611
+ putString("accountId", accountId)
612
+ putString("tenantId", tenantId)
613
+ putString("authority", authority)
614
+ },
615
+ )
616
+ }
617
+
618
+ /**
619
+ * Settle first, publish second. The same notification serves a caller waiting on
620
+ * `enroll()` and every subscriber — including the SDK's own background retries, which
621
+ * have no caller at all (SPEC §13.5).
622
+ */
623
+ internal fun emit(event: String, payload: WritableMap) {
624
+ if (event == "enrollmentResult" || event == "unenrollmentResult") {
625
+ payload.getString("accountId")?.let { settleEnrollment(it, payload.copy()) }
626
+ }
627
+
628
+ // A service-initiated wipe opens the journal before anyone hears about it.
629
+ //
630
+ // Intercepted here because every notification passes through, and this is where the
631
+ // tenant id is in scope. The SDK terminates the process after a wipe, so a
632
+ // subscriber's cleanup may not finish — and with no journal entry there is no
633
+ // `pendingReset`, so the next launch has no idea anything happened and the app's own
634
+ // data and backend session are never dealt with. §7 already lists remote wipe as one
635
+ // of the callers of the single reset path; this makes that true (SPEC §7.4).
636
+ if (event == "wipeRequested") {
637
+ RNIntuneResetJournal.openForServiceWipe(
638
+ context = reactContext,
639
+ accountId = payload.getString("accountId"),
640
+ tenantId = config?.tenantId,
641
+ )
642
+ }
643
+
644
+ events.emit(event, payload)
645
+ }
646
+
647
+ // ------------------------------------------------------------------ helpers
648
+
649
+ /**
650
+ * The SDK's view of an account's enrollment.
651
+ *
652
+ * `getRegisteredAccountStatus(upn, oid)` — the order verified from bytecode, not
653
+ * documentation: the offline implementation checks its *second* argument and warns
654
+ * "called without valid OID", then calls `MAMIdentityManager.create(arg1, arg2)`.
655
+ * Passing the object ID alone lands it in the UPN slot and the registry answers "not
656
+ * registered" for an account that is — observed on device as
657
+ * `getAccountInfo() called for account that is not registered: <oid>;<null>`.
658
+ *
659
+ * The UPN comes from the SDK rather than from this object's fields, because those are
660
+ * in memory and a status read after a restart would otherwise silently miss.
661
+ */
662
+ private fun readStatus(accountId: String): MAMEnrollmentManager.Result? =
663
+ runCatching {
664
+ val manager = MAMComponents.get(MAMEnrollmentManager::class.java) ?: return@runCatching null
665
+ val upn = registeredUpn
666
+ ?: MAMComponents.get(MAMUserInfo::class.java)?.primaryUser
667
+ ?: return@runCatching null
668
+ manager.getRegisteredAccountStatus(upn, accountId)
669
+ }
670
+ .getOrNull()
671
+
672
+ /**
673
+ * True when the MAM SDK is on the classpath. Resolved by name so this file needs no
674
+ * SDK import to answer it, and so a misbuilt app degrades instead of crashing.
675
+ */
676
+ private val sdkAvailable: Boolean by lazy {
677
+ runCatching { Class.forName("com.microsoft.intune.mam.policy.MAMEnrollmentManager") }
678
+ .isSuccess
679
+ }
680
+
681
+ /**
682
+ * Requires a `<queries>` entry for the package in the consumer's manifest on Android
683
+ * 11+. Without it this returns false even when Company Portal is installed — the same
684
+ * omission that makes MSAL fall back to a browser and lose the broker (SPEC §6.2).
685
+ */
686
+ /**
687
+ * Whether Company Portal is present, preferring the SDK's own answer.
688
+ *
689
+ * `MAMComponents.isCompanyPortalInstalled` is first-party and, more importantly, does
690
+ * not depend on the consumer having declared the broker packages in `<queries>`. Our
691
+ * own `packageManager` lookup does: on Android 11+ an undeclared package is invisible,
692
+ * so a consumer who skips that manifest entry gets a confident `false` for a broker
693
+ * that is installed — and then tells the user to install what they already have.
694
+ *
695
+ * That exact failure happened on iOS for a different reason (the wrong URL form), and
696
+ * it is worth not leaving a second way to reach it.
697
+ *
698
+ * The package lookup stays as the fallback: it is the only thing that can answer before
699
+ * `configure`, and `getBrokerStatus` is deliberately answerable then, because the app
700
+ * needs it to decide whether to prompt for a broker install at all (SPEC §13.1).
701
+ */
702
+ private fun isCompanyPortalInstalled(): Boolean =
703
+ runCatching { MAMComponents.isCompanyPortalInstalled(reactContext) }
704
+ .getOrNull()
705
+ ?: isPackageInstalled(COMPANY_PORTAL_PACKAGE)
706
+
707
+ private fun isPackageInstalled(packageName: String): Boolean =
708
+ runCatching { reactContext.packageManager.getPackageInfo(packageName, 0) }.isSuccess
709
+
710
+ private fun notConfigured(method: String, promise: Promise) {
711
+ promise.reject(ERR_NOT_CONFIGURED, "$method was called before configure() resolved.")
712
+ }
713
+
714
+ companion object {
715
+ /** Resolves to "RNIntune" — the name in TurboModuleRegistry.getEnforcing (SPEC §2.2). */
716
+ const val NAME = NativeIntuneSpec.NAME
717
+
718
+ private const val TAG = "RNIntune"
719
+
720
+ private const val COMPANY_PORTAL_PACKAGE = "com.microsoft.windowsintune.companyportal"
721
+
722
+ // Stable rejection codes (SPEC §13.6). Never surface a raw platform error to JS.
723
+ // internal, not private: RNIntuneAuth rejects with the same vocabulary and there must
724
+ // be exactly one definition of each string (SPEC §13.6).
725
+ internal const val ERR_NOT_CONFIGURED = "E_NOT_CONFIGURED"
726
+ private const val ERR_RESET_REQUIRED = "E_RESET_REQUIRED"
727
+ private const val ERR_SDK_UNAVAILABLE = "E_SDK_UNAVAILABLE"
728
+ private const val ERR_INVALID_ACCOUNT_ID = "E_INVALID_ACCOUNT_ID"
729
+ private const val ERR_UPN_REQUIRED = "E_UPN_REQUIRED"
730
+ private const val ERR_RESET_IN_PROGRESS = "E_RESET_IN_PROGRESS"
731
+ private const val ERR_NOT_NEEDED = "E_NOT_NEEDED"
732
+ internal const val ERR_NATIVE = "E_NATIVE"
733
+
734
+ // MSAL outcomes that are ordinary control flow, not faults.
735
+ internal const val ERR_INTERACTION_REQUIRED = "E_INTERACTION_REQUIRED"
736
+ internal const val ERR_USER_CANCELLED = "E_USER_CANCELLED"
737
+ internal const val ERR_EXTERNAL_AUTH_MODE = "E_EXTERNAL_AUTH_MODE"
738
+
739
+ /**
740
+ * Android-only. React Native outlives its Activity, so an interactive sign-in can be
741
+ * asked for when there is no window to present it in — a real state, not a
742
+ * formality, and one the app can recover from by retrying from a mounted screen.
743
+ */
744
+ internal const val ERR_NO_ACTIVITY = "E_NO_ACTIVITY"
745
+
746
+ /**
747
+ * Generous on purpose: enrollment involves a token acquisition and a service round
748
+ * trip, and returning early would report a failure the SDK has not reached.
749
+ */
750
+ private const val ENROLL_TIMEOUT_MS = 90_000L
751
+ }
752
+ }