expo-rotation-module 1.0.0 → 1.0.2

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.
package/README.md CHANGED
@@ -1,77 +1,173 @@
1
1
  # expo-rotation-module
2
2
 
3
- An Expo native module that controls the Android system rotation settings (ACCELEROMETER_ROTATION and USER_ROTATION).
3
+ A small Expo native module that lets an Android app read and control the system rotation settings (ACCELEROMETER_ROTATION and USER_ROTATION) and request the `WRITE_SETTINGS` permission.
4
4
 
5
- ## Overview
6
- This package exposes a small API to check and request the WRITE_SETTINGS permission and to read/set the global rotation mode. This module is Android-only; iOS and web are no-ops.
5
+ This module exposes a tiny JS/TS API and includes Android and iOS native source. The runtime behavior is Android-only; iOS/web are no-ops.
7
6
 
8
- ## Install (local development with pnpm)
7
+ ---
9
8
 
10
- From the module repo:
9
+ ## Usage
11
10
 
12
- - `pnpm install`
13
- - (optional) `pnpm build` — runs the expo-module build scripts
11
+ ### Install
14
12
 
15
- From your Expo app project:
13
+ - From your app project (local development):
14
+ - `pnpm add ../path/to/expo-rotation-module` (or use a published package: `pnpm add expo-rotation-module`)
16
15
 
17
- - Add the module locally: `pnpm add ../expo-rotation-module` (or `pnpm add file:../expo-rotation-module`)
18
- - IMPORTANT: Add the plugin to your app config so the permission is injected during prebuild:
16
+ - IMPORTANT: add the config plugin to your app config so the permission is injected during `prebuild`.
19
17
 
20
- In `app.json` or `app.config.js` add the following to your `expo.plugins` array:
18
+ In `app.json` or `app.config.js` add the package name to `expo.plugins`:
21
19
 
22
20
  ```json
23
- "plugins": [
24
- "expo-rotation-module"
25
- ]
21
+ {
22
+ "expo": {
23
+ "plugins": [
24
+ "expo-rotation-module"
25
+ ]
26
+ }
27
+ }
26
28
  ```
27
29
 
28
- - Run `npx expo prebuild` to apply the config plugin and update `AndroidManifest.xml`.
29
- - Rebuild the Android app (use EAS dev client or Android Studio):
30
- - Recommended: `pnpm add expo-dev-client` then `eas build --profile development --platform android` and run with `expo start --dev-client`.
30
+ - The plugin is implemented in `plugin/index.js` and will insert the `WRITE_SETTINGS` permission into your `AndroidManifest.xml` during `expo prebuild`.
31
+
32
+ - Apply native changes and build the app:
33
+ - `npx expo prebuild` (or `expo run:android` which runs prebuild automatically)
34
+ - Rebuild/install the app on device/emulator (use a custom dev client or a standalone build for WRITE_SETTINGS tests — Expo Go is not suitable).
31
35
 
32
- ## API
36
+ ### API
33
37
 
34
- Import functions:
38
+ Import the module in JS/TS:
35
39
 
36
40
  ```ts
37
- import Rotation, { canWrite, requestWritePermission, getRotationState, setRotationState } from 'expo-rotation-module';
41
+ import Rotation, {
42
+ canWrite,
43
+ requestWritePermission,
44
+ getRotationState,
45
+ setRotationState,
46
+ getPackageName,
47
+ } from 'expo-rotation-module';
38
48
  ```
39
49
 
40
- - `canWrite(): Promise<boolean>` — true if WRITE_SETTINGS is granted (Android M+), otherwise true on older OS.
41
- - `requestWritePermission(): void` — opens Settings where the user can grant WRITE_SETTINGS.
42
- - `getRotationState(): Promise<'AUTOROTATE'|'PORTRAIT'|'LANDSCAPE'>` — reads current rotation state.
43
- - `setRotationState(state): Promise<void>` — sets rotation. Rejects with an Error object that may include a `.code` property.
50
+ - `canWrite(): Promise<boolean>` — returns true if the app has `WRITE_SETTINGS` granted (Android M+). Returns `true` on older OS versions.
51
+ - `requestWritePermission(): void` — opens the Android Settings screen where the user can grant the permission for your app.
52
+ - `getRotationState(): Promise<'AUTOROTATE'|'PORTRAIT'|'LANDSCAPE'>` — reads the current global rotation state. The module reports the coarse axis (PORTRAIT or LANDSCAPE); within those axes the system sensor still allows regular or inverted orientations when auto-rotate is off.
53
+ - `setRotationState(state): Promise<void>` — sets the rotation state. Rejects with an Error that may contain a `code` property (e.g. `E_PERMISSION`).
54
+ - `getPackageName(): Promise<string>` — returns the package name the native module is using (useful for diagnostics).
44
55
 
45
- Error codes on the Error object (if available): `E_PERMISSION`, `E_INVALID_STATE`, `E_SET_ROTATION`, `E_GET_ROTATION`, `E_NO_MODULE`.
56
+ All functions are Android-only. On non-Android platforms the functions are no-ops or return safe defaults.
46
57
 
47
- ## Example
58
+ ### Example
48
59
 
49
60
  ```ts
50
- import * as Rotation from 'expo-rotation-module';
61
+ import Rotation from 'expo-rotation-module';
51
62
 
52
- async function example() {
53
- if (!(await Rotation.canWrite())) {
63
+ async function ensureAndSet() {
64
+ const hasPermission = await Rotation.canWrite();
65
+ if (!hasPermission) {
66
+ // Opens Settings where the user can grant WRITE_SETTINGS for your app
54
67
  Rotation.requestWritePermission();
55
68
  return;
56
69
  }
57
70
 
58
- try {
59
- await Rotation.setRotationState('PORTRAIT');
60
- } catch (e: any) {
61
- if (e.code === 'E_PERMISSION') {
62
- console.warn('Permission missing');
63
- }
64
- }
71
+ await Rotation.setRotationState('PORTRAIT');
65
72
  }
66
73
  ```
67
74
 
68
- ## Plugin
69
- The package contains `plugin/index.js` that injects `android.permission.WRITE_SETTINGS` into the host app's AndroidManifest during `expo prebuild`.
75
+ ### Troubleshooting
70
76
 
71
- ## Package id and Android settings
72
- The Android package/namespace used within module sources is `ktsierra.expo.rotationmodule`.
77
+ - WRITE_SETTINGS switch is greyed out in the Settings screen:
78
+ - Ensure your app has the `WRITE_SETTINGS` permission declared in the manifest. The config plugin adds this when you run `expo prebuild`.
79
+ - Do not test this inside Expo Go — the Settings screen will target the Expo Go package. Build a custom dev client or standalone build.
80
+ - Confirm the package name by calling `Rotation.getPackageName()` and check device logs for the `Opening WRITE_SETTINGS for package: <pkg>` message.
73
81
 
74
- ## Contributing & tests
75
- If you want CI or tests added (TS checks, build smoke tests), I can add a GitHub Actions workflow that runs `pnpm install` and `pnpm build`.
82
+ - ESLint/type errors in consuming projects:
83
+ - Ensure your app can resolve the package. For local linking with pnpm workspaces, `pnpm install` in the app and a restart of the editor/TS server is sometimes required.
76
84
 
77
85
  ---
86
+
87
+ ## Development
88
+
89
+ This section explains how to develop, test and publish this module.
90
+
91
+ ### Repo layout (important files)
92
+
93
+ - `src/` — TypeScript JS wrapper and types.
94
+ - `android/` — Android native sources (Kotlin) and Gradle files.
95
+ - `ios/` — iOS native sources (Swift) and podspec.
96
+ - `plugin/` — config plugin that injects the `WRITE_SETTINGS` permission.
97
+ - `app.plugin.js` — plugin entry point for Expo to discover the config plugin.
98
+ - `index.js` — package entry that re-exports the JS wrapper.
99
+ - `package.json` — package metadata, scripts and publish config.
100
+
101
+ ### Local development
102
+
103
+ 1. Install dependencies in the module repo:
104
+ - `pnpm install`
105
+
106
+ 2. Make changes to `src/` or native code.
107
+
108
+ 3. Build/prepare artifacts (some scripts expect `tsc`/expo-module-scripts):
109
+ - `pnpm run prepare` (runs `expo-module prepare`) or `pnpm run build` (if you prefer).
110
+
111
+ 4. Test in an app:
112
+ - Create or use an example app. From the app root:
113
+ - Add the local package: `pnpm add ../path/to/expo-rotation-module` (or `pnpm add file:...`)
114
+ - Ensure `app.json` includes the plugin (see Usage).
115
+ - Run `npx expo prebuild` and verify `android/app/src/main/AndroidManifest.xml` contains `<uses-permission android:name="android.permission.WRITE_SETTINGS" />`.
116
+ - Build and run the app on device/emulator (`expo run:android` or a dev client build).
117
+
118
+ 5. Faster iteration (optional):
119
+ - Instead of building the package, map Metro to the module `src` in the app `metro.config.js` during development:
120
+ ```js
121
+ // example: app/metro.config.js
122
+ const path = require('path');
123
+ module.exports = {
124
+ resolver: {
125
+ extraNodeModules: {
126
+ 'expo-rotation-module': path.resolve(__dirname, '../path/to/expo-rotation-module/src'),
127
+ },
128
+ },
129
+ watchFolders: [path.resolve(__dirname, '..')],
130
+ };
131
+ ```
132
+ - This lets the app load the TypeScript source directly without rebuilding the package on every change.
133
+
134
+ ### Publishing
135
+
136
+ We publish the source package (native sources included). Before publishing, ensure:
137
+
138
+ - `package.json` fields are correct (`name`, `version`, `main`, `types`, `files`).
139
+ - `devDependencies` contains `typescript` so `expo-module prepare` runs in CI.
140
+ - The plugin and native sources are included in the published package (use the `files` array in `package.json` to control this).
141
+
142
+ Manual publish (local):
143
+
144
+ 1. Prepare the package locally:
145
+ - `pnpm install`
146
+ - `pnpm run prepare`
147
+
148
+ 2. Create a tarball to test what will be published:
149
+ - `npm pack`
150
+ - Install the tarball into a test app: `pnpm add ../expo-rotation-module-<version>.tgz`
151
+ - Run `npx expo prebuild` in the test app to confirm the plugin and native sources are applied.
152
+
153
+ 3. Publish:
154
+ - If your environment has an npm automation token configured in CI, run `npm publish --access public` from the package root.
155
+ - If `prepublishOnly` scripts fail in your environment, you can publish with scripts ignored after preparing locally:
156
+ - `npm publish --access public --ignore-scripts`
157
+
158
+ ### Continuous Integration / GitHub Actions
159
+
160
+ - Recommended CI flow:
161
+ - `pnpm install --frozen-lockfile`
162
+ - `pnpm run prepare` (ensure `typescript` is installed in `devDependencies` so `tsc` is available)
163
+ - Run tests/lint (optional)
164
+ - Bump version, push tags
165
+ - Authenticate to npm (use an automation token or GitHub OIDC if your npm org supports trusted publishers)
166
+ - `npm publish --access public` or `pnpm publish --access public`
167
+
168
+ - Example workflow (summary):
169
+ - Checkout, setup Node + pnpm, `pnpm install`, run `pnpm run prepare`, bump version, publish.
170
+
171
+ ---
172
+
173
+ If you find anything missing or unclear (examples, API signatures, or CI details) open an issue or submit a PR. Contributions welcome.
@@ -2,11 +2,13 @@ package ktsierra.expo.rotationmodule
2
2
 
3
3
  import android.app.Activity
4
4
  import android.content.ContentResolver
5
+ import android.content.Context
5
6
  import android.content.Intent
6
7
  import android.net.Uri
7
8
  import android.os.Build
8
9
  import android.provider.Settings
9
10
  import android.util.Log
11
+ import android.content.pm.ActivityInfo
10
12
  import expo.modules.kotlin.modules.Module
11
13
  import expo.modules.kotlin.modules.ModuleDefinition
12
14
 
@@ -16,6 +18,10 @@ class ExpoRotationModule : Module() {
16
18
  const val TAG = "ExpoRotationModule"
17
19
  }
18
20
 
21
+ private var orientationListener: android.view.OrientationEventListener? = null
22
+ private var desiredAxis: String? = null
23
+ private var lastWrittenRotation: Int = -1
24
+
19
25
  override fun definition() = ModuleDefinition {
20
26
  Name(NAME)
21
27
 
@@ -34,6 +40,20 @@ class ExpoRotationModule : Module() {
34
40
  }
35
41
  }
36
42
 
43
+ // Expose helpers (optional)
44
+ Function("startOrientationListener") {
45
+ val ctx = appContext.activityProvider?.currentActivity ?: appContext.reactContext!!
46
+ startOrientationListener(ctx)
47
+ return@Function null
48
+ }
49
+
50
+ Function("stopOrientationListener") {
51
+ orientationListener?.disable()
52
+ orientationListener = null
53
+ desiredAxis = null
54
+ return@Function null
55
+ }
56
+
37
57
  Function("requestWritePermission") {
38
58
  try {
39
59
  val activity: Activity? = appContext.activityProvider?.currentActivity
@@ -86,9 +106,8 @@ class ExpoRotationModule : Module() {
86
106
  0
87
107
  }
88
108
  return@AsyncFunction when (rotation) {
89
- 0 -> "PORTRAIT"
90
- 1 -> "LANDSCAPE"
91
- 3 -> "LANDSCAPE"
109
+ 0, 2 -> "PORTRAIT"
110
+ 1, 3 -> "LANDSCAPE"
92
111
  else -> "PORTRAIT"
93
112
  }
94
113
  }
@@ -104,23 +123,36 @@ class ExpoRotationModule : Module() {
104
123
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.System.canWrite(ctx)) {
105
124
  throw Exception("E_PERMISSION: WRITE_SETTINGS not granted")
106
125
  }
107
- val resolver: ContentResolver = ctx.contentResolver
126
+
108
127
  when (state) {
109
128
  "AUTOROTATE" -> {
129
+ // Restore system auto-rotate
130
+ val resolver: ContentResolver = ctx.contentResolver
110
131
  Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 1)
132
+ // unregister listener if present
133
+ orientationListener?.disable()
134
+ orientationListener = null
135
+ desiredAxis = null
136
+ lastWrittenRotation = -1
111
137
  }
112
138
  "PORTRAIT" -> {
139
+ // disable system auto-rotate and enable axis-lock with sensor flips on portrait axis
140
+ val resolver: ContentResolver = ctx.contentResolver
113
141
  Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 0)
114
- Settings.System.putInt(resolver, Settings.System.USER_ROTATION, 0)
142
+ desiredAxis = "PORTRAIT"
143
+ startOrientationListener(ctx)
115
144
  }
116
145
  "LANDSCAPE" -> {
146
+ val resolver: ContentResolver = ctx.contentResolver
117
147
  Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 0)
118
- Settings.System.putInt(resolver, Settings.System.USER_ROTATION, 1)
148
+ desiredAxis = "LANDSCAPE"
149
+ startOrientationListener(ctx)
119
150
  }
120
151
  else -> {
121
152
  throw Exception("E_INVALID_STATE: Invalid rotation state: $state")
122
153
  }
123
154
  }
155
+
124
156
  return@AsyncFunction null
125
157
  } catch (e: Exception) {
126
158
  Log.e(TAG, "setRotationState error", e)
@@ -128,4 +160,51 @@ class ExpoRotationModule : Module() {
128
160
  }
129
161
  }
130
162
  }
163
+
164
+ private fun startOrientationListener(ctx: Context) {
165
+ // If listener already exists, do nothing
166
+ if (orientationListener != null) return
167
+
168
+ orientationListener = object : android.view.OrientationEventListener(ctx) {
169
+ override fun onOrientationChanged(orientation: Int) {
170
+ // orientation: 0..359 degrees, or ORIENTATION_UNKNOWN (-1)
171
+ if (orientation == android.view.OrientationEventListener.ORIENTATION_UNKNOWN) return
172
+ // Normalize to one of the 4 Android rotations: 0, 90, 180, 270
173
+ val rot = when {
174
+ orientation in 315..359 || orientation in 0..44 -> 0
175
+ orientation in 45..134 -> 1
176
+ orientation in 135..224 -> 2
177
+ else -> 3
178
+ }
179
+
180
+ try {
181
+ val resolver: ContentResolver = ctx.contentResolver
182
+ // desiredAxis controls whether we want portrait or landscape axis.
183
+ when (desiredAxis) {
184
+ "PORTRAIT" -> {
185
+ // We want 0 or 2 depending on angle
186
+ val target = if (rot == 2) 2 else 0
187
+ if (lastWrittenRotation != target) {
188
+ Settings.System.putInt(resolver, Settings.System.USER_ROTATION, target)
189
+ lastWrittenRotation = target
190
+ }
191
+ }
192
+ "LANDSCAPE" -> {
193
+ // We want 1 or 3 depending on angle
194
+ val target = if (rot == 1) 3 else if (rot == 3) 1 else if (rot == 2) 1 else 3
195
+ // swapped mapping: 90 -> 3, 270 -> 1, fallback 180->1, 0->3
196
+ if (lastWrittenRotation != target) {
197
+ Settings.System.putInt(resolver, Settings.System.USER_ROTATION, target)
198
+ lastWrittenRotation = target
199
+ }
200
+ }
201
+ }
202
+ } catch (e: Exception) {
203
+ Log.e(TAG, "orientation listener write error", e)
204
+ }
205
+ }
206
+ }
207
+
208
+ orientationListener?.enable()
209
+ }
131
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-rotation-module",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Screen Orientation Native Module",
5
5
  "main": "index.js",
6
6
  "types": "src/index.d.ts",
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type RotationState = "AUTOROTATE" | "PORTRAIT" | "LANDSCAPE";
1
+ export type RotationState = 'AUTOROTATE' | 'PORTRAIT' | 'LANDSCAPE';
2
2
 
3
3
  export function canWrite(): Promise<boolean>;
4
4
  export function requestWritePermission(): void;