diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f43a15fa..47a7d71a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: react-native-siri-shortcut, react-native-google-cast, react-native-pdf, + managed-config, ] name: Test ${{ matrix.package }} on Node ${{ matrix.node }} steps: diff --git a/apps/app/app.json b/apps/app/app.json index 4146c461..63883c0a 100644 --- a/apps/app/app.json +++ b/apps/app/app.json @@ -33,7 +33,21 @@ "@config-plugins/react-native-adjust", "@config-plugins/react-native-callkeep", "@config-plugins/android-jsc-intl", - "expo-localization" + "expo-localization", + [ + "@config-plugins/managed-config", + { + "restrictions": [ + { + "key": "test_key", + "title": "Test Title", + "restrictionType": "string", + "description": "Testing managed config plugin", + "defaultValue": "Hello world" + } + ] + } + ] ] } } diff --git a/packages/managed-config/.eslintrc.js b/packages/managed-config/.eslintrc.js new file mode 100644 index 00000000..463a4e1f --- /dev/null +++ b/packages/managed-config/.eslintrc.js @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +module.exports = require("expo-module-scripts/eslintrc.base.js"); diff --git a/packages/managed-config/README.md b/packages/managed-config/README.md new file mode 100644 index 00000000..2a22839b --- /dev/null +++ b/packages/managed-config/README.md @@ -0,0 +1,94 @@ +# @config-plugins/managed-config + +Expo Config Plugin to auto-configure [Managed Configurations](https://developer.android.com/work/managed-configurations) on Android, facilitating the integration with Mobile Device Management (MDM) solutions like [Microsoft Intune](https://www.microsoft.com/en-us/mem/intune) and [VMware Workspace ONE](https://www.vmware.com/products/workspace-one.html). This allows enterprises to remotely manage and configure apps. It can be used with libraries such as [react-native-emm](https://github.com/mattermost/react-native-emm) or [react-native-mdm](https://github.com/robinpowered/react-native-mdm) to allow your app to be managed by an MDM solution. + +## Expo Installation + +> Note: This package cannot be utilized in the "Expo Go" app due to its reliance on custom native code, as detailed in Expo's [customizing the build process guide](https://docs.expo.io/workflow/customizing/). + +First, install the package using yarn, npm, or [`npx expo install`](https://docs.expo.io/workflow/expo-cli/#expo-install) for better version compatibility: + +```sh +npm install @config-plugins/managed-config +``` + +Or + +```sh +yarn add @config-plugins/managed-config +``` + +After installation, add the [config plugin](https://docs.expo.io/guides/config-plugins/) to the [`plugins`](https://docs.expo.io/versions/latest/config/app/#plugins) array in your project's `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [ + [ + "@config-plugins/managed-config", + { + "restrictions": [ + { + "key": "test_key", + "title": "Test Title", + "restrictionType": "string", + "description": "A test description", + "defaultValue": "Default value" + } + // Add more restrictions as needed + ] + } + ] + ] + } +} +``` + +Lastly, you'll need to rebuild your app to apply these changes. Refer to Expo's guide on ["Adding custom native code"](https://docs.expo.io/workflow/customizing/) for instructions on rebuilding your app. + +## API + +The plugin is configured through the `plugins` section of your `app.json` or `app.config.js`. The configuration object accepts a `restrictions` array, where each object represents a managed configuration that your app supports. The structure for each restriction object is as follows: + +### Properties + +- **`key`** (string): A unique identifier for the restriction. +- **`title`** (string): A human-readable title for the restriction, used by the MDM solution to display to admins. +- **`restrictionType`** (string): The type of the restriction. Possible values include `bool`, `string`, `integer`, `choice`, `multi-select`, `hidden`, `bundle`, and `bundle_array`. +- **`description`** (optional, string): A detailed description of the restriction. +- **`defaultValue`** (optional, string | boolean | number | string[] | null): The default value for the restriction. The type depends on the `restrictionType`. +- **`entries`** (optional, string[]): Applicable to `choice` and `multi-select` types. An array of human-readable options. +- **`entryValues`** (optional, string[]): Applicable to `choice` and `multi-select` types. An array of values corresponding to each option in `entries`. + +### Example: + +Adding a configuration for a "dark mode" setting that allows users to choose between 'enabled' and 'disabled'. + +```json +{ + "expo": { + "plugins": [ + [ + "@config-plugins/managed-config", + { + "restrictions": [ + { + "key": "dark_mode", + "title": "Dark Mode", + "restrictionType": "choice", + "entries": ["Enabled", "Disabled"], + "entryValues": ["enabled", "disabled"], + "defaultValue": "disabled", + "description": "Allow users to select dark mode preference" + } + ] + } + ] + ] + } +} +``` + +This plugin simplifies the process of making your app manageable through MDM solutions by automating the setup of managed configurations. + +For further details or support, check the [official documentation](https://developer.android.com/work/managed-configurations) on managed configurations. diff --git a/packages/managed-config/app.plugin.js b/packages/managed-config/app.plugin.js new file mode 100644 index 00000000..944d9787 --- /dev/null +++ b/packages/managed-config/app.plugin.js @@ -0,0 +1 @@ +module.exports = require("./build/withManagedConfig"); diff --git a/packages/managed-config/jest.config.js b/packages/managed-config/jest.config.js new file mode 100644 index 00000000..d52d0349 --- /dev/null +++ b/packages/managed-config/jest.config.js @@ -0,0 +1 @@ +module.exports = require("expo-module-scripts/jest-preset-plugin"); diff --git a/packages/managed-config/package.json b/packages/managed-config/package.json new file mode 100644 index 00000000..36362773 --- /dev/null +++ b/packages/managed-config/package.json @@ -0,0 +1,36 @@ +{ + "name": "@config-plugins/managed-config", + "version": "0.0.0", + "description": "Config plugin for managed-config package", + "main": "build/withManagedConfig.js", + "types": "build/withManagedConfig.d.ts", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/expo/config-plugins.git", + "directory": "packages/managed-config" + }, + "scripts": { + "build": "expo-module build", + "clean": "expo-module clean", + "lint": "expo-module lint", + "test": "expo-module test", + "prepare": "expo-module prepare", + "prepublishOnly": "expo-module prepublishOnly", + "expo-module": "expo-module" + }, + "keywords": [ + "react", + "expo", + "config-plugins", + "prebuild", + "managed-config", + "expo-50" + ], + "peerDependencies": { + "expo": "^50" + }, + "devDependencies": { + "expo-module-scripts": "^3.4.1" + } +} diff --git a/packages/managed-config/src/__tests__/generateAppRestrictionsContent.test.ts b/packages/managed-config/src/__tests__/generateAppRestrictionsContent.test.ts new file mode 100644 index 00000000..3c0c6bff --- /dev/null +++ b/packages/managed-config/src/__tests__/generateAppRestrictionsContent.test.ts @@ -0,0 +1,438 @@ +import { AppRestriction } from "../appRestrictionTypes"; +import { generateAppRestrictionsContent } from "../generateAppRestrictionsContent"; + +describe("generateAppRestrictionsContent", () => { + describe("string", () => { + it('correctly generates XML content for "string" type restriction with all possible fields', () => { + const restrictions: AppRestriction[] = [ + { + key: "user_nickname", + title: "User Nickname", + restrictionType: "string", + description: "The nickname of the user", + defaultValue: "ExpoFan", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'string' type restriction with only required fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_nickname", + title: "User Nickname", + restrictionType: "string", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("integer", () => { + it("correctly generates XML content for 'integer' type restriction with all possible fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_age", + title: "User Age", + restrictionType: "integer", + description: "The age of the user", + defaultValue: 25, + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'integer' type restriction with only required fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_age", + title: "User Age", + restrictionType: "integer", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("bool", () => { + it("correctly generates XML content for 'bool' type restriction with all possible fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_is_active", + title: "User Active", + restrictionType: "bool", + description: "Whether the user is active or not", + defaultValue: true, + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'bool' type restriction with only required fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_is_active", + title: "User Active", + restrictionType: "bool", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("hidden", () => { + it("correctly generates XML content for 'hidden' type restriction with string type", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_secret", + title: "User Secret", + restrictionType: "hidden", + defaultValue: "4a5678", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'hidden' type restriction with integer type", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_secret", + title: "User Secret", + restrictionType: "hidden", + defaultValue: false, + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'hidden' type restriction with bool type", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_secret", + title: "User Secret", + restrictionType: "hidden", + defaultValue: 123456, + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("bundle and bundle_array", () => { + it("correctly generates XML content for 'bundle' type restriction with nested restrictions", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_settings", + title: "User Settings", + restrictionType: "bundle", + restrictions: [ + { + key: "user_theme", + title: "User Theme", + restrictionType: "string", + defaultValue: "dark", + }, + { + key: "notifications_enabled", + title: "Notifications Enabled", + restrictionType: "bool", + defaultValue: true, + }, + ], + }, + ]; + + const expectedXmlContent = ` + + + + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + + it("correctly generates XML content for 'bundle_array' type restriction with nested restrictions", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_accounts", + title: "User Accounts", + restrictionType: "bundle_array", + restrictions: [ + { + key: "account_1", + title: "Account 1", + restrictionType: "bundle", + restrictions: [ + { + key: "username", + title: "Username", + restrictionType: "string", + defaultValue: "user1", + }, + { + key: "active", + title: "Active", + restrictionType: "bool", + defaultValue: true, + }, + ], + }, + { + key: "account_2", + title: "Account 2", + restrictionType: "bundle", + restrictions: [ + { + key: "username", + title: "Username", + restrictionType: "string", + defaultValue: "user2", + }, + { + key: "active", + title: "Active", + restrictionType: "bool", + defaultValue: false, + }, + ], + }, + ], + }, + ]; + + const expectedXmlContent = ` + + + + + + + + + + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("choice", () => { + it("correctly generates XML content for 'choice' type restriction with all fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_color_preference", + title: "User Color Preference", + restrictionType: "choice", + description: "The preferred color theme for the user interface", + entries: ["Light", "Dark", "System default"], + entryValues: ["light", "dark", "default"], + defaultValue: "default", + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); + + describe("multi-select", () => { + it("correctly generates XML content for 'multi-select' type restriction with all fields", () => { + const restrictions: AppRestriction[] = [ + { + key: "user_interests", + title: "User Interests", + restrictionType: "multi-select", + description: "The interests selected by the user", + entries: ["Technology", "Sports", "Arts", "Science"], + entryValues: ["tech", "sports", "arts", "science"], + defaultValue: ["tech", "science"], + }, + ]; + + const expectedXmlContent = ` + + +`.trim(); + + const generatedXmlContent = + generateAppRestrictionsContent(restrictions).trim(); + expect(generatedXmlContent).toEqual(expectedXmlContent); + }); + }); +}); diff --git a/packages/managed-config/src/appRestrictionTypes.ts b/packages/managed-config/src/appRestrictionTypes.ts new file mode 100644 index 00000000..6d91e782 --- /dev/null +++ b/packages/managed-config/src/appRestrictionTypes.ts @@ -0,0 +1,54 @@ +type BaseRestriction = { + key: string; + title: string; + description?: string; + defaultValue?: boolean | number | string | string[]; // Adjust based on restrictionType requirements +}; + +type BooleanRestriction = BaseRestriction & { + restrictionType: "bool"; + defaultValue?: boolean; +}; + +type StringRestriction = BaseRestriction & { + restrictionType: "string"; + defaultValue?: string; +}; + +type IntegerRestriction = BaseRestriction & { + restrictionType: "integer"; + defaultValue?: number; +}; + +type BundleRestriction = BaseRestriction & { + restrictionType: "bundle" | "bundle_array"; + restrictions: AppRestriction[]; +}; + +type ChoiceRestriction = BaseRestriction & { + restrictionType: "choice"; + entries: string[]; + entryValues: string[]; + defaultValue?: string; +}; + +type MultiSelectRestriction = BaseRestriction & { + restrictionType: "multi-select"; + entries: string[]; + entryValues: string[]; + defaultValue?: string[]; +}; + +type HiddenRestriction = BaseRestriction & { + restrictionType: "hidden"; + defaultValue: boolean | number | string; // Hidden must have a defaultValue +}; + +export type AppRestriction = + | BooleanRestriction + | StringRestriction + | IntegerRestriction + | ChoiceRestriction + | MultiSelectRestriction + | HiddenRestriction + | BundleRestriction; diff --git a/packages/managed-config/src/constants.ts b/packages/managed-config/src/constants.ts new file mode 100644 index 00000000..86509274 --- /dev/null +++ b/packages/managed-config/src/constants.ts @@ -0,0 +1,3 @@ +export const AppRestrictionsFileNameRoot = "app_restrictions"; +export const AppRestrictionsFileExtension = "xml"; +export const AppRestrictionsFileName = `${AppRestrictionsFileNameRoot}.${AppRestrictionsFileExtension}`; diff --git a/packages/managed-config/src/generateAppRestrictionsContent.ts b/packages/managed-config/src/generateAppRestrictionsContent.ts new file mode 100644 index 00000000..f6fb69b2 --- /dev/null +++ b/packages/managed-config/src/generateAppRestrictionsContent.ts @@ -0,0 +1,62 @@ +import { AppRestriction } from "./appRestrictionTypes"; + +export const generateAppRestrictionsContent = ( + restrictions: AppRestriction[] +): string => { + const content = getConfigContent(restrictions); + return ` + +${content} +`; +}; + +const getConfigContent = (restrictions: AppRestriction[]) => { + return restrictions.map(createAppRestrictionXML).join("\n"); +}; + +const createAppRestrictionXML = (restriction: AppRestriction): string => { + let xml = ` = (config, { restrictions }) => { + config = withAppRestrictions(config, { restrictions }); + + return config; +}; + +export default withEmm; diff --git a/packages/managed-config/src/withAppRestrictionsFile.ts b/packages/managed-config/src/withAppRestrictionsFile.ts new file mode 100644 index 00000000..a31558ab --- /dev/null +++ b/packages/managed-config/src/withAppRestrictionsFile.ts @@ -0,0 +1,65 @@ +import { + ConfigPlugin, + withAndroidManifest, + withDangerousMod, + AndroidConfig, +} from "@expo/config-plugins"; +import fs from "fs"; +import path from "path"; + +import { AppRestriction } from "./appRestrictionTypes"; +import { generateAppRestrictionsContent } from "./generateAppRestrictionsContent"; + +const appRestrictionsFileNameRoot = "app_restrictions"; +const appRestrictionsFileExtension = "xml"; +const appRestrictionsFileName = `${appRestrictionsFileNameRoot}.${appRestrictionsFileExtension}`; + +const withAppRestrictionsConfigFile: ConfigPlugin<{ + restrictions: AppRestriction[]; +}> = (config, { restrictions }) => { + return withDangerousMod(config, [ + "android", + (config) => { + const folder = path.join( + config.modRequest.platformProjectRoot, + `app/src/main/res/xml` + ); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync( + path.join(folder, appRestrictionsFileName), + generateAppRestrictionsContent(restrictions), + { + encoding: "utf8", + } + ); + return config; + }, + ]); +}; + +export const withAppRestrictions: ConfigPlugin<{ + restrictions: AppRestriction[]; +}> = (config, props) => { + if ( + typeof props.restrictions === "object" && + props.restrictions.length === 0 + ) { + // if restrictions is an empty array, skip... + return config; + } + + config = withAppRestrictionsConfigFile(config, props); + return withAndroidManifest(config, (config) => { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow( + config.modResults + ); + application["meta-data"] = application["meta-data"] || []; + application["meta-data"].push({ + $: { + "android:name": "android.content.APP_RESTRICTIONS", + "android:resource": `@xml/${appRestrictionsFileNameRoot}`, + }, + }); + return config; + }); +}; diff --git a/packages/managed-config/src/withManagedConfig.ts b/packages/managed-config/src/withManagedConfig.ts new file mode 100644 index 00000000..f5598f08 --- /dev/null +++ b/packages/managed-config/src/withManagedConfig.ts @@ -0,0 +1,78 @@ +import { + ConfigPlugin, + createRunOncePlugin, + withDangerousMod, + withAndroidManifest, + AndroidConfig, +} from "@expo/config-plugins"; +import fs from "fs"; +import path from "path"; + +import { AppRestriction } from "./appRestrictionTypes"; +import { + AppRestrictionsFileName, + AppRestrictionsFileNameRoot, +} from "./constants"; +import { generateAppRestrictionsContent } from "./generateAppRestrictionsContent"; + +const pkg = { + // Prevent this plugin from being run more than once. + // This pattern enables users to safely migrate off of this + // out-of-tree `@config-plugins/managed-config` to a future + // upstream plugin in `managed-config` + name: "managed-config", + // Indicates that this plugin is dangerously linked to a module, + // and might not work with the latest version of that module. + version: "UNVERSIONED", +}; + +const withAppRestrictionsConfigFile: ConfigPlugin<{ + restrictions: AppRestriction[]; +}> = (config, { restrictions }) => { + return withDangerousMod(config, [ + "android", + (config) => { + const folder = path.join( + config.modRequest.platformProjectRoot, + `app/src/main/res/xml` + ); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync( + path.join(folder, AppRestrictionsFileName), + generateAppRestrictionsContent(restrictions), + { + encoding: "utf8", + } + ); + return config; + }, + ]); +}; + +const withManagedConfig: ConfigPlugin<{ + restrictions: AppRestriction[]; +}> = (config, props) => { + if ( + typeof props.restrictions === "object" && + props.restrictions.length === 0 + ) { + return config; + } + + config = withAppRestrictionsConfigFile(config, props); + return withAndroidManifest(config, (config) => { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow( + config.modResults + ); + application["meta-data"] = application["meta-data"] || []; + application["meta-data"].push({ + $: { + "android:name": "android.content.APP_RESTRICTIONS", + "android:resource": `@xml/${AppRestrictionsFileNameRoot}`, + }, + }); + return config; + }); +}; + +export default createRunOncePlugin(withManagedConfig, pkg.name, pkg.version); diff --git a/packages/managed-config/tsconfig.json b/packages/managed-config/tsconfig.json new file mode 100644 index 00000000..901571ed --- /dev/null +++ b/packages/managed-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo-module-scripts/tsconfig.plugin", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +}