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,78 @@
1
+ package com.reactnativeintune
2
+
3
+ import android.content.Context
4
+ import org.json.JSONArray
5
+ import org.json.JSONObject
6
+ import java.io.File
7
+
8
+ /**
9
+ * The per-tenant MSAL configuration, written to a file at `configure()` time (SPEC §3.3).
10
+ *
11
+ * MSAL on Android normally reads this from a raw resource, which is a build-time artifact
12
+ * and therefore fixed at compile time. This module configures a tenant at runtime, so the
13
+ * JSON is generated and handed to the `File` overload of the factory instead. That is the
14
+ * whole reason this file exists.
15
+ *
16
+ * Every key here was read out of MSAL 8.4.2 rather than from documentation: the shape came
17
+ * from `res/raw/msal_default_config.json` inside the AAR, and `authority_url` / `type` /
18
+ * `default` were confirmed as the serialized names on
19
+ * `com.microsoft.identity.common.java.authorities.Authority`. A key MSAL does not
20
+ * recognise is ignored silently, which is exactly the kind of failure that costs a day.
21
+ */
22
+ internal object RNIntuneMsalConfig {
23
+
24
+ private const val FILE_NAME = "rn-intune-msal-config.json"
25
+
26
+ /**
27
+ * Writes the config and returns the file.
28
+ *
29
+ * Rewritten on every `configure()` rather than cached: the tenant can change between
30
+ * launches, and a stale file would point MSAL at the previous customer.
31
+ */
32
+ fun write(
33
+ context: Context,
34
+ clientId: String,
35
+ authority: String,
36
+ redirectUri: String,
37
+ ): File {
38
+ val file = File(context.filesDir, FILE_NAME)
39
+ file.writeText(build(clientId, authority, redirectUri).toString(2))
40
+ return file
41
+ }
42
+
43
+ /** Exposed for tests and for `getDiagnostics`; contains no secrets. */
44
+ fun build(clientId: String, authority: String, redirectUri: String): JSONObject =
45
+ JSONObject().apply {
46
+ put("client_id", clientId)
47
+ put("redirect_uri", redirectUri)
48
+
49
+ // `authority_url` rather than an `audience` block: the URL is what `configure()`
50
+ // already carries, and it sidesteps having to map a tenant onto one of MSAL's
51
+ // audience type strings.
52
+ put(
53
+ "authorities",
54
+ JSONArray().put(
55
+ JSONObject().apply {
56
+ put("type", "AAD")
57
+ put("authority_url", authority)
58
+ put("default", true)
59
+ },
60
+ ),
61
+ )
62
+
63
+ // Single identity is a decided constraint, not a default we are accepting
64
+ // (SPEC §9, CLAUDE.md rule 10). SINGLE also gives us
65
+ // ISingleAccountPublicClientApplication, whose API cannot express two signed-in
66
+ // accounts — the constraint is enforced by the type, not by our discipline.
67
+ put("account_mode", "SINGLE")
68
+
69
+ // Both required for brokered auth, which MAM enrollment depends on. Without the
70
+ // first, MSAL will not hand the request to Company Portal at all.
71
+ put("broker_redirect_uri_registered", true)
72
+ put("authorization_user_agent", "DEFAULT")
73
+
74
+ // Sovereign clouds. Harmless for the public cloud and the difference between
75
+ // working and not for a customer in one (SPEC §3.3).
76
+ put("multiple_clouds_supported", true)
77
+ }
78
+ }
@@ -0,0 +1,187 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.microsoft.intune.mam.client.app.MAMComponents
6
+ import com.microsoft.intune.mam.client.notification.MAMNotificationReceiver
7
+ import com.microsoft.intune.mam.client.notification.MAMNotificationReceiverRegistry
8
+ import com.microsoft.intune.mam.policy.MAMEnrollmentManager
9
+ import com.microsoft.intune.mam.policy.notification.MAMEnrollmentNotification
10
+ import com.microsoft.intune.mam.policy.notification.MAMNotification
11
+ import com.microsoft.intune.mam.policy.notification.MAMNotificationType
12
+ import com.microsoft.intune.mam.policy.notification.MAMUserNotification
13
+
14
+ /**
15
+ * The Android counterpart of the iOS delegates: the SDK reports enrollment, policy and
16
+ * wipe through notifications rather than a delegate protocol.
17
+ *
18
+ * These arrive without the app having asked for anything — the SDK has its own
19
+ * enrollment retry schedule, and a wipe is service-initiated. So nothing here resolves a
20
+ * promise; it publishes events and whoever is waiting correlates (SPEC §6.4, §12.5).
21
+ */
22
+ internal object RNIntuneNotifications {
23
+
24
+ /** Unified status strings. Must match `EnrollmentStatus` in src/types.ts. */
25
+ private const val SUCCEEDED = "succeeded"
26
+ private const val NOT_LICENSED = "notLicensed"
27
+ private const val FAILED = "failed"
28
+ private const val PENDING = "pending"
29
+ private const val AUTHORIZATION_NEEDED = "authorizationNeeded"
30
+ private const val COMPANY_PORTAL_REQUIRED = "companyPortalRequired"
31
+ private const val WRONG_USER = "wrongUser"
32
+ private const val UNENROLLED = "unenrolled"
33
+ private const val UNENROLLMENT_FAILED = "unenrollmentFailed"
34
+ private const val UNKNOWN = "unknown"
35
+
36
+ /**
37
+ * Maps the SDK's ten-value result onto the unified vocabulary. The reasoning for each
38
+ * row is SPEC §4.1 — read it before changing one. Getting `notLicensed` and `failed`
39
+ * the wrong way round has opposite consequences for the user (SPEC §8).
40
+ *
41
+ * `Result` is an enum, so `when` is exhaustive without an `else`: a value added by a
42
+ * future SDK becomes a compile error here rather than silence. Anything that does slip
43
+ * through still degrades to `unknown` instead of throwing.
44
+ */
45
+ fun unifiedStatus(result: MAMEnrollmentManager.Result?): String =
46
+ when (result) {
47
+ MAMEnrollmentManager.Result.ENROLLMENT_SUCCEEDED,
48
+ // The account is managed through the MDM channel rather than MAM-WE. A managed,
49
+ // non-blocking state — nativeCode keeps the distinction for support logs.
50
+ MAMEnrollmentManager.Result.MDM_ENROLLED -> SUCCEEDED
51
+ MAMEnrollmentManager.Result.NOT_LICENSED -> NOT_LICENSED
52
+ MAMEnrollmentManager.Result.ENROLLMENT_FAILED -> FAILED
53
+ MAMEnrollmentManager.Result.PENDING -> PENDING
54
+ MAMEnrollmentManager.Result.AUTHORIZATION_NEEDED -> AUTHORIZATION_NEEDED
55
+ MAMEnrollmentManager.Result.COMPANY_PORTAL_REQUIRED -> COMPANY_PORTAL_REQUIRED
56
+ MAMEnrollmentManager.Result.WRONG_USER -> WRONG_USER
57
+ MAMEnrollmentManager.Result.UNENROLLMENT_SUCCEEDED -> UNENROLLED
58
+ MAMEnrollmentManager.Result.UNENROLLMENT_FAILED -> UNENROLLMENT_FAILED
59
+ // Android has no equivalent of iOS's LicensedNotTargeted: the service reports
60
+ // NOT_LICENSED for an untargeted account too, so `notTargeted` never appears from
61
+ // this platform. Recorded in SPEC §4.1 rather than faked here.
62
+ null -> UNKNOWN
63
+ }
64
+
65
+ /**
66
+ * An `EnrollmentResult` built from a status the SDK already holds, rather than from a
67
+ * notification.
68
+ *
69
+ * Needed because `registerAccountForMAM` on an already-registered account is a silent
70
+ * no-op: no notification is ever posted, so `enroll()` has to answer from the registry
71
+ * or wait for a result that is not coming.
72
+ */
73
+ fun resultFrom(status: MAMEnrollmentManager.Result, accountId: String? = null): WritableMap =
74
+ result(
75
+ status = unifiedStatus(status),
76
+ accountId = accountId,
77
+ nativeCode = status.name,
78
+ nativeMessage = "Read from the registry; the account was already registered.",
79
+ )
80
+
81
+ /** Shapes an `EnrollmentResult` (SPEC §4.1). */
82
+ private fun result(
83
+ status: String,
84
+ accountId: String?,
85
+ nativeCode: String,
86
+ nativeMessage: String,
87
+ ): WritableMap =
88
+ Arguments.createMap().apply {
89
+ putString("status", status)
90
+ putString("accountId", accountId)
91
+ putString("nativeCode", nativeCode)
92
+ putString("nativeMessage", nativeMessage)
93
+ // Android does not surface a restart requirement; iOS does, via its policy
94
+ // delegate. Kept in the shape so one branch in JS serves both platforms.
95
+ putBoolean("restartRequired", false)
96
+ }
97
+
98
+ /**
99
+ * Registers one receiver per notification type we act on, and returns them so they can
100
+ * be unregistered.
101
+ *
102
+ * `emit` is expected to queue when JS is not listening yet — a wipe notification lost
103
+ * during startup means the reset machine never runs (SPEC §4.4).
104
+ */
105
+ fun register(emit: (event: String, payload: WritableMap) -> Unit): List<Registration> {
106
+ val registry =
107
+ MAMComponents.get(MAMNotificationReceiverRegistry::class.java) ?: return emptyList()
108
+
109
+ val registrations =
110
+ listOf(
111
+ // Enrollment, unenrollment and the SDK's own background retries all arrive here.
112
+ Registration(MAMNotificationType.MAM_ENROLLMENT_RESULT) { notification ->
113
+ val enrollment = notification as? MAMEnrollmentNotification ?: return@Registration true
114
+ val status = unifiedStatus(enrollment.enrollmentResult)
115
+ val payload =
116
+ result(
117
+ status = status,
118
+ // The Entra object ID. getUserIdentity() is the UPN and is deliberately
119
+ // not read (CLAUDE.md rule 3).
120
+ accountId = enrollment.userOid,
121
+ nativeCode = enrollment.enrollmentResult?.name.orEmpty(),
122
+ nativeMessage = enrollment.error?.toString().orEmpty(),
123
+ )
124
+
125
+ val event =
126
+ if (status == UNENROLLED || status == UNENROLLMENT_FAILED) {
127
+ "unenrollmentResult"
128
+ } else {
129
+ "enrollmentResult"
130
+ }
131
+ emit(event, payload)
132
+ true
133
+ },
134
+
135
+ // An administrator changed policy. The app has to re-read whatever UI it gates.
136
+ Registration(MAMNotificationType.REFRESH_POLICY) {
137
+ emit("policyChanged", Arguments.createMap())
138
+ true
139
+ },
140
+
141
+ // Service-initiated. Can arrive with no prior app call at all, which is the whole
142
+ // reason the emitter is live from construction.
143
+ Registration(MAMNotificationType.WIPE_USER_DATA) { notification ->
144
+ emit(
145
+ "wipeRequested",
146
+ Arguments.createMap().apply {
147
+ putString("accountId", (notification as? MAMUserNotification)?.userOid)
148
+ },
149
+ )
150
+ // Returning true tells the SDK the app handled the wipe. The app's own data is
151
+ // cleared through the reset journal, because the process may not survive long
152
+ // enough to do it inline (SPEC §7).
153
+ true
154
+ },
155
+
156
+ // The account is no longer managed — policy has been removed rather than wiped.
157
+ Registration(MAMNotificationType.MANAGEMENT_REMOVED) { notification ->
158
+ emit(
159
+ "unenrollmentResult",
160
+ result(
161
+ status = UNENROLLED,
162
+ accountId = (notification as? MAMUserNotification)?.userOid,
163
+ nativeCode = "MANAGEMENT_REMOVED",
164
+ nativeMessage = "",
165
+ ),
166
+ )
167
+ true
168
+ },
169
+ )
170
+
171
+ registrations.forEach { registry.registerReceiver(it.receiver, it.type) }
172
+ return registrations
173
+ }
174
+
175
+ fun unregister(registrations: List<Registration>) {
176
+ val registry =
177
+ MAMComponents.get(MAMNotificationReceiverRegistry::class.java) ?: return
178
+ registrations.forEach { registry.unregisterReceiver(it.receiver, it.type) }
179
+ }
180
+
181
+ internal class Registration(
182
+ val type: MAMNotificationType,
183
+ handle: (MAMNotification) -> Boolean,
184
+ ) {
185
+ val receiver = MAMNotificationReceiver { notification -> handle(notification) }
186
+ }
187
+ }
@@ -0,0 +1,33 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class RNIntunePackage : BaseReactPackage() {
10
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
11
+ return if (name == RNIntuneModule.NAME) {
12
+ RNIntuneModule(reactContext)
13
+ } else {
14
+ null
15
+ }
16
+ }
17
+
18
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
19
+ mapOf(
20
+ RNIntuneModule.NAME to ReactModuleInfo(
21
+ name = RNIntuneModule.NAME,
22
+ className = RNIntuneModule.NAME,
23
+ canOverrideExistingModule = false,
24
+ // The event emitter must be live from construction, not from the first
25
+ // enroll() — a service-initiated wipe can arrive before JS calls anything
26
+ // (SPEC §4.4, §12.4). Revisit this flag when the emitter lands.
27
+ needsEagerInit = false,
28
+ isCxxModule = false,
29
+ isTurboModule = true
30
+ )
31
+ )
32
+ }
33
+ }
@@ -0,0 +1,108 @@
1
+ package com.reactnativeintune
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.bridge.WritableMap
6
+ import com.microsoft.intune.mam.client.app.MAMComponents
7
+ import com.microsoft.intune.mam.client.identity.MAMPolicyManager
8
+ import com.microsoft.intune.mam.policy.MAMUserInfo
9
+ import com.microsoft.intune.mam.policy.OpenLocation
10
+ import com.microsoft.intune.mam.policy.SaveLocation
11
+
12
+ /**
13
+ * PolicySnapshot (SPEC §4.3).
14
+ *
15
+ * For adapting the host app's own UI — nothing here is enforcement. PIN prompts,
16
+ * screenshot blocking and encryption all happen inside the SDK and are not driven by
17
+ * these values.
18
+ *
19
+ * There is no clipboard field and there cannot be one: neither platform exposes a
20
+ * clipboard policy getter. `canSaveToPersonal` and `canOpenFromUnmanaged` serve the same
21
+ * purpose using values the SDK will actually answer.
22
+ *
23
+ * Note the class package: `MAMPolicyManager` is in `client.identity`, not `policy`.
24
+ */
25
+ internal object RNIntunePolicy {
26
+
27
+ fun snapshot(context: ReactApplicationContext, accountId: String?): WritableMap {
28
+ // Ask the SDK who the primary user is rather than relying only on the account id the
29
+ // caller happens to be holding: that one lives in memory and is gone after a restart,
30
+ // while the SDK's survives — and a policy read that silently loses its identity is
31
+ // exactly the failure fixed on iOS.
32
+ val primaryOid = runCatching { MAMComponents.get(MAMUserInfo::class.java)?.primaryUserOID }
33
+ .getOrNull()
34
+ ?.takeIf { it.isNotEmpty() }
35
+ val oid = accountId ?: primaryOid
36
+
37
+ // `getPolicy(context)` never returns null — with no identity it answers with a
38
+ // default *permissive* policy. So it cannot be used to decide whether the app is
39
+ // managed, and `isManaged` must not be hardcoded from it being non-null. Observed on
40
+ // device: a fresh install that had never enrolled reported `isManaged: true`.
41
+ val policy = runCatching {
42
+ if (oid != null) MAMPolicyManager.getPolicyForIdentityOID(oid)
43
+ else MAMPolicyManager.getPolicy(context)
44
+ }.getOrNull() ?: return unmanaged(primaryOid != null)
45
+
46
+ val managed = oid != null &&
47
+ runCatching { MAMPolicyManager.getIsIdentityOIDManaged(oid) }.getOrDefault(false)
48
+
49
+ return Arguments.createMap().apply {
50
+ putBoolean("isManaged", managed)
51
+ putBoolean(
52
+ "canSaveToLocal",
53
+ policy.getIsSaveToLocationAllowedForOID(SaveLocation.LOCAL, oid),
54
+ )
55
+ // Not getIsSaveToPersonalAllowed(), which is deprecated — and the location form is
56
+ // what iOS uses too (isSaveToAllowedForLocation:Other), so both platforms answer
57
+ // the same question.
58
+ putBoolean(
59
+ "canSaveToPersonal",
60
+ policy.getIsSaveToLocationAllowedForOID(SaveLocation.OTHER, oid),
61
+ )
62
+ putBoolean(
63
+ "canOpenFromUnmanaged",
64
+ policy.getIsOpenFromLocationAllowedForOID(OpenLocation.OTHER, oid),
65
+ )
66
+ putBoolean("screenshotAllowed", policy.isScreenCaptureAllowed)
67
+ // Everything else the SDK reports. Explicitly outside semver: anything depended on
68
+ // here has to be promoted to a typed field first (SPEC §4.3).
69
+ putMap(
70
+ "raw",
71
+ Arguments.createMap().apply {
72
+ putString("isPinRequired", policy.isPinRequired.toString())
73
+ putString("isManagedBrowserRequired", policy.isManagedBrowserRequired.toString())
74
+ putString("isContactSyncAllowed", policy.isContactSyncAllowed.toString())
75
+ putString("hasSaveRestriction", policy.diagnosticHasSaveRestriction().toString())
76
+ putString("hasOpenRestriction", policy.diagnosticHasOpenRestriction().toString())
77
+ putString(
78
+ "fileEncryptionInUse",
79
+ policy.diagnosticIsFileEncryptionInUse().toString(),
80
+ )
81
+ // Diagnostic, not policy: an all-permissive snapshot means either no identity
82
+ // resolved or a genuinely unrestricted tenant, and this says which. Boolean
83
+ // only — the account id itself must not travel here (CLAUDE.md rule 3).
84
+ putString("hasPrimaryAccount", (primaryOid != null).toString())
85
+ },
86
+ )
87
+ }
88
+ }
89
+
90
+ /**
91
+ * An app with no policy is fully permissive. Reporting `false` everywhere would hide
92
+ * functionality that nothing is restricting.
93
+ */
94
+ private fun unmanaged(hasPrimaryAccount: Boolean = false): WritableMap =
95
+ Arguments.createMap().apply {
96
+ putBoolean("isManaged", false)
97
+ putBoolean("canSaveToLocal", true)
98
+ putBoolean("canSaveToPersonal", true)
99
+ putBoolean("canOpenFromUnmanaged", true)
100
+ putBoolean("screenshotAllowed", true)
101
+ putMap(
102
+ "raw",
103
+ Arguments.createMap().apply {
104
+ putString("hasPrimaryAccount", hasPrimaryAccount.toString())
105
+ },
106
+ )
107
+ }
108
+ }
@@ -0,0 +1,85 @@
1
+ package com.reactnativeintune
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.microsoft.intune.mam.client.app.MAMComponents
6
+ import com.microsoft.intune.mam.policy.MAMEnrollmentManager
7
+
8
+ /**
9
+ * The reset sequence (SPEC §7).
10
+ *
11
+ * Separate from [RNIntuneResetJournal] on purpose: the journal is storage, this is the
12
+ * order of operations. The order is the part that is easy to get wrong — the unregister
13
+ * has to precede any token purge, and the process is not guaranteed to survive it.
14
+ */
15
+ internal object RNIntuneReset {
16
+
17
+ private const val TAG = "RNIntuneReset"
18
+
19
+ /**
20
+ * Opens the journal, then unregisters. Every step is written down *before* it is
21
+ * attempted, because after `unregisterAccountForMAM` there may be no process left to
22
+ * write anything.
23
+ */
24
+ fun run(
25
+ context: Context,
26
+ accountId: String?,
27
+ upn: String?,
28
+ tenantId: String?,
29
+ wipe: Boolean,
30
+ reason: String,
31
+ ) {
32
+ RNIntuneResetJournal.open(
33
+ context = context,
34
+ accountId = accountId,
35
+ upn = upn,
36
+ tenantId = tenantId,
37
+ wipe = wipe,
38
+ reason = reason,
39
+ )
40
+
41
+ if (upn != null) {
42
+ val manager = MAMComponents.get(MAMEnrollmentManager::class.java)
43
+ // `unregisterAccountForMAM(upn, oid)`. The second parameter is the object ID —
44
+ // identified from bytecode, not documentation: the offline implementation checks
45
+ // its second argument and warns "called without valid OID; identity may be
46
+ // ambiguous", then calls `MAMIdentityManager.create(arg1, arg2)`.
47
+ //
48
+ // The single-argument form is not merely deprecated, it is worse: it leaves the
49
+ // identity ambiguous, and that warning was visible in logcat on a real device
50
+ // before this was fixed. `getRegisteredAccountStatus` has the same shape.
51
+ runCatching { manager?.unregisterAccountForMAM(upn, accountId.orEmpty()) }
52
+ .onFailure { Log.w(TAG, "unregisterAccountForMAM failed: ${it.message}") }
53
+ }
54
+
55
+ // Reached only if the process survived.
56
+ RNIntuneResetJournal.advance(context, RNIntuneResetJournal.STAGE_CLEANING_AUTH)
57
+
58
+ // MSAL cache cleanup happens in RNIntuneModule.doReset, immediately after this
59
+ // returns, and not here: this object has no MSAL client and should not grow a
60
+ // dependency on one — it owns the order of operations, not the pieces. What matters
61
+ // is that it comes *after* the unregister above, because that call needs an Intune
62
+ // token which comes from the very cache being cleared (SPEC §7 step 4).
63
+
64
+ RNIntuneResetJournal.advance(context, RNIntuneResetJournal.STAGE_CLEANING_LOCAL)
65
+ }
66
+
67
+ /**
68
+ * Closes the journal, but only once the account is verified gone.
69
+ *
70
+ * @param statusOf reads the SDK's registration status for an account ID.
71
+ * @return false when the reset did not take, leaving the journal open for a retry.
72
+ */
73
+ fun complete(
74
+ context: Context,
75
+ statusOf: (String) -> MAMEnrollmentManager.Result?,
76
+ ): Boolean {
77
+ val entry = RNIntuneResetJournal.read(context) ?: return true
78
+
79
+ val stillRegistered = entry.accountId?.let { statusOf(it) != null } ?: false
80
+ if (stillRegistered) return false
81
+
82
+ RNIntuneResetJournal.close(context)
83
+ return true
84
+ }
85
+ }
@@ -0,0 +1,140 @@
1
+ package com.reactnativeintune
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+
6
+ /**
7
+ * The reset journal (SPEC §7).
8
+ *
9
+ * Unregistering an account that had policy enforced makes the SDK wipe that account's
10
+ * data, and **the process terminating is expected behaviour, not a crash**. Code after
11
+ * the unregister call does not run. So the sequence is not held in memory: each stage is
12
+ * written down before it is attempted, and the next launch reads what was in flight.
13
+ *
14
+ * IDLE -> UNREGISTERING -> CLEANING_AUTH -> CLEANING_LOCAL -> IDLE
15
+ *
16
+ * No SDK dependency: the journal has to be readable at launch, before anything is
17
+ * configured.
18
+ *
19
+ * **On the storage choice.** SPEC §7 requires this to survive the wipe, which on Android
20
+ * means it must carry no identity tag — a tagged file is deleted with the account's data
21
+ * and the app restarts with no knowledge that a reset was in flight. These preferences
22
+ * are written without ever setting a thread or process identity (the module never calls
23
+ * `setCurrentThreadAccountId`, per CLAUDE.md rule 10), so the MAM plugin's rewritten file
24
+ * layer sees them as unmanaged.
25
+ *
26
+ * [verify] against a real wipe once a tenant exists. Survival across process death is
27
+ * exercised and works; survival across a wipe cannot be tested without a policy-targeted
28
+ * account, and it is the half that matters more.
29
+ */
30
+ internal object RNIntuneResetJournal {
31
+
32
+ /** Stage strings. Must match `ResetStage` in src/types.ts. */
33
+ const val STAGE_UNREGISTERING = "unregistering"
34
+ const val STAGE_CLEANING_AUTH = "cleaningAuth"
35
+ const val STAGE_CLEANING_LOCAL = "cleaningLocal"
36
+
37
+ private const val PREFS = "com.reactnativeintune.resetJournal"
38
+ private const val KEY_STAGE = "stage"
39
+ private const val KEY_ACCOUNT_ID = "accountId"
40
+ private const val KEY_UPN = "upn"
41
+ private const val KEY_TENANT_ID = "tenantId"
42
+ private const val KEY_WIPE = "wipe"
43
+ private const val KEY_REASON = "reason"
44
+ private const val KEY_STARTED_AT = "startedAt"
45
+
46
+ data class Entry(
47
+ val stage: String,
48
+ val accountId: String?,
49
+ val upn: String?,
50
+ val tenantId: String?,
51
+ val wipe: Boolean,
52
+ val reason: String,
53
+ val startedAt: Long,
54
+ )
55
+
56
+ private fun prefs(context: Context): SharedPreferences =
57
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
58
+
59
+ fun read(context: Context): Entry? {
60
+ val p = prefs(context)
61
+ val stage = p.getString(KEY_STAGE, null) ?: return null
62
+ return Entry(
63
+ stage = stage,
64
+ accountId = p.getString(KEY_ACCOUNT_ID, null),
65
+ upn = p.getString(KEY_UPN, null),
66
+ tenantId = p.getString(KEY_TENANT_ID, null),
67
+ wipe = p.getBoolean(KEY_WIPE, false),
68
+ reason = p.getString(KEY_REASON, "").orEmpty(),
69
+ startedAt = p.getLong(KEY_STARTED_AT, 0L),
70
+ )
71
+ }
72
+
73
+ fun stage(context: Context): String? = prefs(context).getString(KEY_STAGE, null)
74
+
75
+ /**
76
+ * Writes the entry at [STAGE_UNREGISTERING]. Uses `commit()` rather than `apply()`
77
+ * deliberately: the caller is about to make a call that may end the process, and an
78
+ * entry still queued for a background write is an entry that never existed.
79
+ */
80
+ fun open(
81
+ context: Context,
82
+ accountId: String?,
83
+ upn: String?,
84
+ tenantId: String?,
85
+ wipe: Boolean,
86
+ reason: String,
87
+ ) {
88
+ prefs(context)
89
+ .edit()
90
+ .putString(KEY_STAGE, STAGE_UNREGISTERING)
91
+ .putString(KEY_ACCOUNT_ID, accountId)
92
+ .putString(KEY_UPN, upn)
93
+ .putString(KEY_TENANT_ID, tenantId)
94
+ .putBoolean(KEY_WIPE, wipe)
95
+ .putString(KEY_REASON, reason)
96
+ .putLong(KEY_STARTED_AT, System.currentTimeMillis())
97
+ .commit()
98
+ }
99
+
100
+ /**
101
+ * Opens the journal for a wipe the *service* started, unless one is already open.
102
+ *
103
+ * A service-initiated wipe is one of the callers of the reset path (SPEC §7, "one code
104
+ * path, many callers"), and it needs the journal for the same reason `reset()` does: the
105
+ * SDK terminates the process, so the consumer's cleanup may not finish and nothing else
106
+ * will come back for it. With no entry there is no `pendingReset`, so the next launch
107
+ * has no idea anything happened.
108
+ *
109
+ * Returns false when an entry already exists — a reset in flight must not be
110
+ * overwritten, since its `reason` and `accountId` are what the resumed sequence acts on.
111
+ */
112
+ fun openForServiceWipe(context: Context, accountId: String?, tenantId: String?): Boolean {
113
+ if (read(context) != null) return false
114
+ // `wipe = true` records what happened rather than requesting it: the SDK is already
115
+ // wiping. `reason` is what lets the consumer's handler tell an administrator revoking
116
+ // access from a user logging out (SPEC §7.4).
117
+ open(
118
+ context = context,
119
+ accountId = accountId,
120
+ upn = null,
121
+ tenantId = tenantId,
122
+ wipe = true,
123
+ reason = "remote_wipe",
124
+ )
125
+ return true
126
+ }
127
+
128
+ fun advance(context: Context, stage: String) {
129
+ if (prefs(context).getString(KEY_STAGE, null) == null) return
130
+ prefs(context).edit().putString(KEY_STAGE, stage).commit()
131
+ }
132
+
133
+ /**
134
+ * Only after the account is verified gone. An unverified reset stays open and is
135
+ * retried on the next launch rather than being marked done (SPEC §7 step 5).
136
+ */
137
+ fun close(context: Context) {
138
+ prefs(context).edit().clear().commit()
139
+ }
140
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Expo's entry point for this package's config plugin.
3
+ *
4
+ * `app.plugin.js` at the package root is the convention Expo looks for, which is what
5
+ * lets a consumer write the package name alone in their `plugins` array rather than a
6
+ * path into it:
7
+ *
8
+ * "plugins": [["react-native-intune", { "androidSignatureHash": "..." }]]
9
+ */
10
+ module.exports = require('./plugin/withIntune');
package/ios/RNIntune.h ADDED
@@ -0,0 +1,20 @@
1
+ //
2
+ // Codegen conformance and the JS bridge. ObjC++ because getTurboModule: returns a
3
+ // std::shared_ptr and the generated parameter structs are C++ — neither can be written
4
+ // in Objective-C or Swift (SPEC §5.1.1).
5
+ //
6
+ // This class unpacks and forwards. It holds no SDK state and makes no SDK calls; that
7
+ // is RNIntuneCore's job, in plain .m (CLAUDE.md rule 7).
8
+ //
9
+ // The umbrella header name comes from package.json -> codegenConfig.name; the protocol
10
+ // name from the spec file name (src/NativeIntune.ts -> NativeIntuneSpec).
11
+ //
12
+ #import <RNIntuneSpec/RNIntuneSpec.h>
13
+ #import <React/RCTEventEmitter.h>
14
+
15
+ /// RCTEventEmitter, not NSObject: the emitter has to be live from module construction
16
+ /// because a service-initiated wipe can arrive before the app calls anything, and events
17
+ /// fired before JS subscribes are queued rather than dropped (SPEC §4.4).
18
+ @interface RNIntune : RCTEventEmitter <NativeIntuneSpec>
19
+
20
+ @end