@symbiote-native/store-review 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 A. Prokopenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # @symbiote-native/store-review
2
+
3
+ A wrapper package for [SymbioteNative](../../README.md) that makes
4
+ [`expo-store-review`](https://github.com/expo/expo/tree/main/packages/expo-store-review)
5
+ — prompting the platform's native in-app review flow — usable from **every** adapter, React,
6
+ Vue, and Angular, not just React. Like [`@symbiote-native/device`](../device) and
7
+ [`@symbiote-native/local-auth`](../local-auth), every export here is a one-shot async call with
8
+ no per-instance state, so there is no hook/composable/service to wrap — the React, Vue, and
9
+ Angular entry points are plain re-exports of the same `core`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @symbiote-native/store-review
15
+ ```
16
+
17
+ `expo-store-review` and `expo-modules-core` come along as regular dependencies, pinned to exact
18
+ versions — never install either yourself, and never add the `expo` meta-package to your project
19
+ (it bundles its own Metro/Babel pipeline, which conflicts with this project's own).
20
+
21
+ ## Required one-time step: native autolinking wiring
22
+
23
+ Unlike a plain RN native module, `expo-store-review`'s native code is discovered by
24
+ `expo-modules-autolinking`, not RN's own `react-native.config.cjs` mechanism — this needs wiring
25
+ into the native host app **once**, covering this package and every other `expo-modules-core`
26
+ package with zero further changes:
27
+
28
+ | Platform | Touches |
29
+ |---|---|
30
+ | iOS | `ios/Podfile` — add `use_expo_modules!` |
31
+ | iOS | `AppDelegate.swift` — Expo's runtime-bootstrap hook |
32
+ | Android | `settings.gradle` / `app/build.gradle` — resolve and include the Expo Gradle projects |
33
+ | Android | `MainApplication.kt` — Expo's bootstrap hook, plus a hand-written native-module name map (there's no `expo` meta-package here to auto-generate one) |
34
+
35
+ Full mechanics live in the `symbiote-expo-native-module` project skill. Reference
36
+ implementation: `examples/expo-react/ios/Podfile` and
37
+ `examples/expo-react/android/app/src/main/java/com/canaryexpo/MainApplication.kt`.
38
+
39
+ `expo-store-review` needs no runtime permission on either platform.
40
+
41
+ ## Shape
42
+
43
+ ```
44
+ src/core/ isAvailableAsync / requestReview / hasAction, plus the IStoreReviewUrlOptions
45
+ type. native-module.ts resolves the native module via expo-modules-core's
46
+ requireNativeModule.
47
+ src/react/ @symbiote-native/store-review/react — export * from '../core'
48
+ src/vue/ @symbiote-native/store-review/vue — export * from '../core'
49
+ src/angular/ @symbiote-native/store-review/angular — export * from '../core'
50
+ ```
51
+
52
+ No per-adapter lifecycle wrapper exists because there's nothing to subscribe to or clean up —
53
+ each adapter entry is a single-file re-export.
54
+
55
+ ## Use it
56
+
57
+ ```tsx
58
+ // React
59
+ import { Button } from '@symbiote-native/react';
60
+ import { requestReview } from '@symbiote-native/store-review/react';
61
+
62
+ function RateAppButton() {
63
+ return (
64
+ <Button
65
+ title="Rate this app"
66
+ onPress={() =>
67
+ requestReview({
68
+ iosAppStoreUrl: 'https://apps.apple.com/app/id123456789',
69
+ androidPlayStoreUrl: 'https://play.google.com/store/apps/details?id=com.example.app',
70
+ })
71
+ }
72
+ />
73
+ );
74
+ }
75
+ ```
76
+
77
+ ```vue
78
+ <!-- Vue -->
79
+ <script setup lang="ts">
80
+ import { Button } from '@symbiote-native/vue';
81
+ import { requestReview } from '@symbiote-native/store-review/vue';
82
+
83
+ function onRatePress() {
84
+ void requestReview({
85
+ iosAppStoreUrl: 'https://apps.apple.com/app/id123456789',
86
+ androidPlayStoreUrl: 'https://play.google.com/store/apps/details?id=com.example.app',
87
+ });
88
+ }
89
+ </script>
90
+
91
+ <template>
92
+ <Button title="Rate this app" @press="onRatePress" />
93
+ </template>
94
+ ```
95
+
96
+ ```ts
97
+ // Angular
98
+ import { Component } from '@angular/core';
99
+ import { Button } from '@symbiote-native/angular';
100
+ import { requestReview } from '@symbiote-native/store-review/angular';
101
+
102
+ @Component({
103
+ standalone: true,
104
+ imports: [Button],
105
+ template: `<Button title="Rate this app" (press)="onRatePress()" />`,
106
+ })
107
+ export class RateAppButton {
108
+ onRatePress(): void {
109
+ void requestReview({
110
+ iosAppStoreUrl: 'https://apps.apple.com/app/id123456789',
111
+ androidPlayStoreUrl: 'https://play.google.com/store/apps/details?id=com.example.app',
112
+ });
113
+ }
114
+ }
115
+ ```
116
+
117
+ These snippets are not yet demoed in a real canary screen — treat them as realistic reference
118
+ code, not a trimmed-down copy of a running demo.
119
+
120
+ ## API
121
+
122
+ ```ts
123
+ export type IStoreReviewUrlOptions = {
124
+ iosAppStoreUrl?: string;
125
+ androidPlayStoreUrl?: string;
126
+ };
127
+
128
+ isAvailableAsync(): Promise<boolean>
129
+ requestReview(options?: IStoreReviewUrlOptions): Promise<void>
130
+ hasAction(options?: IStoreReviewUrlOptions): Promise<boolean>
131
+ ```
132
+
133
+ ```ts
134
+ import { requestReview, isAvailableAsync, hasAction } from '@symbiote-native/store-review';
135
+ // or the framework-scoped entry points — identical surface, re-exported verbatim:
136
+ import { requestReview } from '@symbiote-native/store-review/react';
137
+ import { requestReview } from '@symbiote-native/store-review/vue';
138
+ import { requestReview } from '@symbiote-native/store-review/angular';
139
+ ```
140
+
141
+ ## Notes
142
+
143
+ - **Deliberate deviation from upstream — no `expo-constants` dependency.** Upstream's
144
+ `storeUrl()` reads `Constants.expoConfig.ios.appStoreUrl`/`.android.playStoreUrl` off
145
+ `expo-constants`, which requires an Expo-CLI-generated manifest (`app.config`/EAS/updates)
146
+ this bare, Metro-only project never produces (see the `symbiote-expo-package-catalog` skill,
147
+ row 9 — `expo-constants` was skipped for exactly this reason). So this port trims that surface:
148
+ every function that would have read the manifest instead takes an optional
149
+ `IStoreReviewUrlOptions` argument (`iosAppStoreUrl` / `androidPlayStoreUrl`), and the caller
150
+ supplies the store URL explicitly instead of it being resolved from `app.json`/`app.config.js`.
151
+ There is no standalone `storeUrl()` export — its logic lives inline as a private helper,
152
+ since it now takes the same options argument every public function does.
153
+ - **`requestReview()` prefers the native in-app review flow** (iOS `SKStoreReviewController`,
154
+ Android's Play Core in-app review API) and only falls back to opening the supplied store URL
155
+ via `Linking` when the native flow is unavailable — matching upstream's own fallback order.
156
+ - **A missing URL on the fallback path logs a warning, never throws** — matching upstream's own
157
+ `console.warn`-and-continue behavior, just with different wording (pointing at the
158
+ `IStoreReviewUrlOptions` argument instead of `app.json`).
159
+
160
+ ## Test it
161
+
162
+ No Fabric/Descriptor angle at all — every export here is a pure async-function surface, never a
163
+ view or per-instance state. Tests inject a fake native-module object in place of the real
164
+ `requireNativeModule` resolution, plus a stubbed `expo-modules-core` `Platform` and a stubbed
165
+ `react-native` `Linking` (`src/core/store-review.test.ts`, `vitest`) — no `installFabric()`, no
166
+ ViewConfig. Native rendering itself is verified on-device (see the parent
167
+ [README](../../README.md) for the project's testing model).
168
+
169
+ Native autolinking wiring for `expo-modules-core` packages is already done in the four Expo
170
+ canary apps (`examples/expo-react`, `examples/expo-vue-sfc`, `examples/expo-vue-tsx`,
171
+ `examples/expo-angular`) via `@symbiote-native/local-auth`/`@symbiote-native/sensors` — this
172
+ package reuses that same wiring with zero further app-side changes, since
173
+ `expo-modules-autolinking` discovers any `expo-modules-core` package already present in
174
+ `node_modules`. A dedicated demo screen has not been wired into those canaries yet.
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/store-review/angular: the Angular entry over the framework-agnostic core.
2
+ // Same reasoning as the React/Vue entries — no per-instance state or event stream to wrap in a
3
+ // service, so this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, requestReview, hasAction } from './store-review';
2
+ export type { IStoreReviewUrlOptions } from './store-review';
@@ -0,0 +1 @@
1
+ export { isAvailableAsync, requestReview, hasAction } from './store-review';
@@ -0,0 +1,5 @@
1
+ export type INativeStoreReviewModule = {
2
+ isAvailableAsync?(): Promise<boolean>;
3
+ requestReview?(): Promise<void>;
4
+ };
5
+ export declare const expoStoreReview: INativeStoreReviewModule;
@@ -0,0 +1,3 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ const EXPO_STORE_REVIEW_MODULE_NAME = 'ExpoStoreReview';
3
+ export const expoStoreReview = requireNativeModule(EXPO_STORE_REVIEW_MODULE_NAME);
@@ -0,0 +1,30 @@
1
+ export type IStoreReviewUrlOptions = {
2
+ iosAppStoreUrl?: string;
3
+ androidPlayStoreUrl?: string;
4
+ };
5
+ /**
6
+ * Determines if the platform has the capabilities to use `requestReview()`'s native flow.
7
+ * @return
8
+ * - On iOS, resolves to `true` unless the app is distributed through TestFlight.
9
+ * - On Android, resolves to `true` when the Play Store app is installed — upstream's own JSDoc
10
+ * claims "Android 5.0+", but its `StoreReviewModule.kt` checks only for that package.
11
+ *
12
+ * A `true` from either platform still does not mean a prompt will appear.
13
+ */
14
+ export declare function isAvailableAsync(): Promise<boolean>;
15
+ /**
16
+ * In ideal circumstances this opens a native modal letting the user pick a star rating that's
17
+ * then applied to the App Store/Play Store, without leaving the app. If the native flow is
18
+ * unavailable, falls back to opening the store URL supplied via `options`.
19
+ *
20
+ * Resolving does NOT mean a prompt appeared — neither store reports that back, by design, so
21
+ * there is nothing to branch on. On Android it only shows for a build installed from Google
22
+ * Play; a sideloaded build runs the whole Play Core flow and displays nothing. Both stores also
23
+ * enforce a quota.
24
+ */
25
+ export declare function requestReview(options?: IStoreReviewUrlOptions): Promise<void>;
26
+ /**
27
+ * @return `true` if `requestReview()` is capable of directing the user to some kind of store
28
+ * review flow — either the native flow is available, or a store URL was supplied via `options`.
29
+ */
30
+ export declare function hasAction(options?: IStoreReviewUrlOptions): Promise<boolean>;
@@ -0,0 +1,68 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-store-review/src/StoreReview.ts (sdk-57), with
2
+ // one deliberate deviation: upstream's `storeUrl()` reads
3
+ // `Constants.expoConfig.ios.appStoreUrl`/`.android.playStoreUrl` off `expo-constants`, which this
4
+ // project doesn't depend on (symbiote-expo-package-catalog skill, row 9 — expo-constants hard-
5
+ // depends on an Expo-CLI-generated manifest a bare/Metro-only project like this one never
6
+ // produces). So the store URL is supplied explicitly by the caller instead of being read from a
7
+ // manifest — every function below takes an optional `IStoreReviewUrlOptions` in place of
8
+ // upstream's implicit `Constants` read.
9
+ import { Platform } from 'expo-modules-core';
10
+ import { Linking } from 'react-native';
11
+ import { expoStoreReview } from './native-module';
12
+ /**
13
+ * Determines if the platform has the capabilities to use `requestReview()`'s native flow.
14
+ * @return
15
+ * - On iOS, resolves to `true` unless the app is distributed through TestFlight.
16
+ * - On Android, resolves to `true` when the Play Store app is installed — upstream's own JSDoc
17
+ * claims "Android 5.0+", but its `StoreReviewModule.kt` checks only for that package.
18
+ *
19
+ * A `true` from either platform still does not mean a prompt will appear.
20
+ */
21
+ export async function isAvailableAsync() {
22
+ return expoStoreReview.isAvailableAsync?.() ?? false;
23
+ }
24
+ function urlFor(options) {
25
+ if (Platform.OS === 'ios') {
26
+ return options?.iosAppStoreUrl ?? null;
27
+ }
28
+ if (Platform.OS === 'android') {
29
+ return options?.androidPlayStoreUrl ?? null;
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * In ideal circumstances this opens a native modal letting the user pick a star rating that's
35
+ * then applied to the App Store/Play Store, without leaving the app. If the native flow is
36
+ * unavailable, falls back to opening the store URL supplied via `options`.
37
+ *
38
+ * Resolving does NOT mean a prompt appeared — neither store reports that back, by design, so
39
+ * there is nothing to branch on. On Android it only shows for a build installed from Google
40
+ * Play; a sideloaded build runs the whole Play Core flow and displays nothing. Both stores also
41
+ * enforce a quota.
42
+ */
43
+ export async function requestReview(options) {
44
+ if (expoStoreReview.requestReview) {
45
+ return expoStoreReview.requestReview();
46
+ }
47
+ const url = urlFor(options);
48
+ if (url) {
49
+ const supported = await Linking.canOpenURL(url);
50
+ if (!supported) {
51
+ console.warn(`StoreReview.requestReview(): Can't open store url: ${url}`);
52
+ }
53
+ else {
54
+ await Linking.openURL(url);
55
+ }
56
+ }
57
+ else {
58
+ console.warn('StoreReview.requestReview(): no native review flow and no store URL was provided — ' +
59
+ 'pass { iosAppStoreUrl, androidPlayStoreUrl } to requestReview().');
60
+ }
61
+ }
62
+ /**
63
+ * @return `true` if `requestReview()` is capable of directing the user to some kind of store
64
+ * review flow — either the native flow is available, or a store URL was supplied via `options`.
65
+ */
66
+ export async function hasAction(options) {
67
+ return !!urlFor(options) || (await isAvailableAsync());
68
+ }
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,5 @@
1
+ // @symbiote-native/store-review/react: the React entry over the framework-agnostic core.
2
+ // Upstream ships free async functions, no per-instance state and no event stream — there is
3
+ // nothing for a hook to wrap, so this is a plain re-export, mirroring
4
+ // @symbiote-native/device's/@symbiote-native/local-auth's React entry.
5
+ export * from '../core';
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/store-review/vue: the Vue entry over the framework-agnostic core. Same
2
+ // reasoning as the React entry — no per-instance state or event stream to wire onto Vue's
3
+ // reactivity, so this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,5 @@
1
+ // @symbiote-native/store-review/angular: the Angular entry over the framework-agnostic core.
2
+ // Same reasoning as the React/Vue entries — no per-instance state or event stream to wrap in a
3
+ // service, so this is a plain re-export.
4
+ export * from '../core';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/angular/index.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,+FAA+F;AAC/F,yCAAyC;AACzC,cAAc,SAAS,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, requestReview, hasAction } from './store-review';
2
+ export type { IStoreReviewUrlOptions } from './store-review';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, requestReview, hasAction } from './store-review';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,5 @@
1
+ export type INativeStoreReviewModule = {
2
+ isAvailableAsync?(): Promise<boolean>;
3
+ requestReview?(): Promise<void>;
4
+ };
5
+ export declare const expoStoreReview: INativeStoreReviewModule;
@@ -0,0 +1,4 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ const EXPO_STORE_REVIEW_MODULE_NAME = 'ExpoStoreReview';
3
+ export const expoStoreReview = requireNativeModule(EXPO_STORE_REVIEW_MODULE_NAME);
4
+ //# sourceMappingURL=native-module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-module.js","sourceRoot":"","sources":["../../src/core/native-module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAExD,MAAM,6BAA6B,GAAG,iBAAiB,CAAC;AAWxD,MAAM,CAAC,MAAM,eAAe,GAAG,mBAAmB,CAChD,6BAA6B,CAC9B,CAAC"}
@@ -0,0 +1,30 @@
1
+ export type IStoreReviewUrlOptions = {
2
+ iosAppStoreUrl?: string;
3
+ androidPlayStoreUrl?: string;
4
+ };
5
+ /**
6
+ * Determines if the platform has the capabilities to use `requestReview()`'s native flow.
7
+ * @return
8
+ * - On iOS, resolves to `true` unless the app is distributed through TestFlight.
9
+ * - On Android, resolves to `true` when the Play Store app is installed — upstream's own JSDoc
10
+ * claims "Android 5.0+", but its `StoreReviewModule.kt` checks only for that package.
11
+ *
12
+ * A `true` from either platform still does not mean a prompt will appear.
13
+ */
14
+ export declare function isAvailableAsync(): Promise<boolean>;
15
+ /**
16
+ * In ideal circumstances this opens a native modal letting the user pick a star rating that's
17
+ * then applied to the App Store/Play Store, without leaving the app. If the native flow is
18
+ * unavailable, falls back to opening the store URL supplied via `options`.
19
+ *
20
+ * Resolving does NOT mean a prompt appeared — neither store reports that back, by design, so
21
+ * there is nothing to branch on. On Android it only shows for a build installed from Google
22
+ * Play; a sideloaded build runs the whole Play Core flow and displays nothing. Both stores also
23
+ * enforce a quota.
24
+ */
25
+ export declare function requestReview(options?: IStoreReviewUrlOptions): Promise<void>;
26
+ /**
27
+ * @return `true` if `requestReview()` is capable of directing the user to some kind of store
28
+ * review flow — either the native flow is available, or a store URL was supplied via `options`.
29
+ */
30
+ export declare function hasAction(options?: IStoreReviewUrlOptions): Promise<boolean>;
@@ -0,0 +1,69 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-store-review/src/StoreReview.ts (sdk-57), with
2
+ // one deliberate deviation: upstream's `storeUrl()` reads
3
+ // `Constants.expoConfig.ios.appStoreUrl`/`.android.playStoreUrl` off `expo-constants`, which this
4
+ // project doesn't depend on (symbiote-expo-package-catalog skill, row 9 — expo-constants hard-
5
+ // depends on an Expo-CLI-generated manifest a bare/Metro-only project like this one never
6
+ // produces). So the store URL is supplied explicitly by the caller instead of being read from a
7
+ // manifest — every function below takes an optional `IStoreReviewUrlOptions` in place of
8
+ // upstream's implicit `Constants` read.
9
+ import { Platform } from 'expo-modules-core';
10
+ import { Linking } from 'react-native';
11
+ import { expoStoreReview } from './native-module';
12
+ /**
13
+ * Determines if the platform has the capabilities to use `requestReview()`'s native flow.
14
+ * @return
15
+ * - On iOS, resolves to `true` unless the app is distributed through TestFlight.
16
+ * - On Android, resolves to `true` when the Play Store app is installed — upstream's own JSDoc
17
+ * claims "Android 5.0+", but its `StoreReviewModule.kt` checks only for that package.
18
+ *
19
+ * A `true` from either platform still does not mean a prompt will appear.
20
+ */
21
+ export async function isAvailableAsync() {
22
+ return expoStoreReview.isAvailableAsync?.() ?? false;
23
+ }
24
+ function urlFor(options) {
25
+ if (Platform.OS === 'ios') {
26
+ return options?.iosAppStoreUrl ?? null;
27
+ }
28
+ if (Platform.OS === 'android') {
29
+ return options?.androidPlayStoreUrl ?? null;
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * In ideal circumstances this opens a native modal letting the user pick a star rating that's
35
+ * then applied to the App Store/Play Store, without leaving the app. If the native flow is
36
+ * unavailable, falls back to opening the store URL supplied via `options`.
37
+ *
38
+ * Resolving does NOT mean a prompt appeared — neither store reports that back, by design, so
39
+ * there is nothing to branch on. On Android it only shows for a build installed from Google
40
+ * Play; a sideloaded build runs the whole Play Core flow and displays nothing. Both stores also
41
+ * enforce a quota.
42
+ */
43
+ export async function requestReview(options) {
44
+ if (expoStoreReview.requestReview) {
45
+ return expoStoreReview.requestReview();
46
+ }
47
+ const url = urlFor(options);
48
+ if (url) {
49
+ const supported = await Linking.canOpenURL(url);
50
+ if (!supported) {
51
+ console.warn(`StoreReview.requestReview(): Can't open store url: ${url}`);
52
+ }
53
+ else {
54
+ await Linking.openURL(url);
55
+ }
56
+ }
57
+ else {
58
+ console.warn('StoreReview.requestReview(): no native review flow and no store URL was provided — ' +
59
+ 'pass { iosAppStoreUrl, androidPlayStoreUrl } to requestReview().');
60
+ }
61
+ }
62
+ /**
63
+ * @return `true` if `requestReview()` is capable of directing the user to some kind of store
64
+ * review flow — either the native flow is available, or a store URL was supplied via `options`.
65
+ */
66
+ export async function hasAction(options) {
67
+ return !!urlFor(options) || (await isAvailableAsync());
68
+ }
69
+ //# sourceMappingURL=store-review.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-review.js","sourceRoot":"","sources":["../../src/core/store-review.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,0DAA0D;AAC1D,kGAAkG;AAClG,+FAA+F;AAC/F,0FAA0F;AAC1F,gGAAgG;AAChG,yFAAyF;AACzF,wCAAwC;AACxC,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAOlD;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB;IACpC,OAAO,eAAe,CAAC,gBAAgB,EAAE,EAAE,IAAI,KAAK,CAAC;AACvD,CAAC;AAED,SAAS,MAAM,CAAC,OAAgC;IAC9C,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,OAAO,OAAO,EAAE,cAAc,IAAI,IAAI,CAAC;IACzC,CAAC;IACD,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;QAC9B,OAAO,OAAO,EAAE,mBAAmB,IAAI,IAAI,CAAC;IAC9C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAgC;IAClE,IAAI,eAAe,CAAC,aAAa,EAAE,CAAC;QAClC,OAAO,eAAe,CAAC,aAAa,EAAE,CAAC;IACzC,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IAC5B,IAAI,GAAG,EAAE,CAAC;QACR,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAChD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,CAAC,IAAI,CAAC,sDAAsD,GAAG,EAAE,CAAC,CAAC;QAC5E,CAAC;aAAM,CAAC;YACN,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,IAAI,CACV,qFAAqF;YACnF,kEAAkE,CACrE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAgC;IAC9D,OAAO,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,gBAAgB,EAAE,CAAC,CAAC;AACzD,CAAC"}
@@ -0,0 +1,12 @@
1
+ {
2
+ "android": {
3
+ "gradleProjectName": "expo-store-review",
4
+ "modules": [
5
+ {
6
+ "importPath": "expo.modules.storereview.StoreReviewModule",
7
+ "className": "StoreReviewModule",
8
+ "nativeName": "ExpoStoreReview"
9
+ }
10
+ ]
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@symbiote-native/store-review",
3
+ "version": "0.0.1",
4
+ "description": "expo-store-review wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters. Prompts the platform's native in-app review flow, with a caller-supplied store-URL fallback in place of upstream's expo-constants-based lookup (this project doesn't depend on expo-constants).",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
9
+ "directory": "packages/store-review"
10
+ },
11
+ "homepage": "https://github.com/OneEyed1366/symbiote-native/tree/master/packages/store-review#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/OneEyed1366/symbiote-native/issues"
14
+ },
15
+ "author": "Andrey Prokopenko <psevdoproger@gmail.com>",
16
+ "type": "module",
17
+ "main": "./build/core/index.js",
18
+ "module": "./build/core/index.js",
19
+ "types": "./build/core/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./build/core/index.d.ts",
23
+ "default": "./build/core/index.js"
24
+ },
25
+ "./vue": {
26
+ "types": "./build/vue/index.d.ts",
27
+ "default": "./build/vue/index.js"
28
+ },
29
+ "./react": {
30
+ "types": "./build/react/index.d.ts",
31
+ "default": "./build/react/index.js"
32
+ },
33
+ "./angular": {
34
+ "types": "./build-ngc/angular/index.d.ts",
35
+ "react-native": "./build-ngc/angular/index.js",
36
+ "default": "./src/angular/index.ts"
37
+ }
38
+ },
39
+ "files": [
40
+ "src",
41
+ "build",
42
+ "build-ngc",
43
+ "native-link.json"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "expo-store-review": "57.0.1",
50
+ "expo-modules-core": "57.0.5"
51
+ },
52
+ "peerDependencies": {
53
+ "@symbiote-native/engine": ">=0.1.7",
54
+ "@angular/core": ">=20",
55
+ "@vue/runtime-core": "^3.5.13",
56
+ "react": ">=19.0.0",
57
+ "react-native": ">=0.86",
58
+ "vue": ">=3.5.0",
59
+ "@symbiote-native/react": "0.2.8",
60
+ "@symbiote-native/angular": "0.6.1",
61
+ "@symbiote-native/vue": "0.3.8"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@symbiote-native/angular": {
65
+ "optional": true
66
+ },
67
+ "@symbiote-native/react": {
68
+ "optional": true
69
+ },
70
+ "@symbiote-native/vue": {
71
+ "optional": true
72
+ },
73
+ "@angular/core": {
74
+ "optional": true
75
+ },
76
+ "@vue/runtime-core": {
77
+ "optional": true
78
+ },
79
+ "react": {
80
+ "optional": true
81
+ },
82
+ "vue": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@angular/compiler": "^22",
88
+ "@angular/compiler-cli": "^22",
89
+ "@angular/core": "^22",
90
+ "@types/node": "^26.0.0",
91
+ "@types/react": "^19.2.0",
92
+ "@vue/runtime-core": "^3.5.13",
93
+ "react": "19.2.3",
94
+ "typescript": "~6.0.0",
95
+ "@symbiote-native/angular": "0.6.1",
96
+ "@symbiote-native/react": "0.2.8",
97
+ "@symbiote-native/vue": "0.3.8",
98
+ "@symbiote-native/test-utils": "0.1.6",
99
+ "@symbiote-native/engine": "0.1.7"
100
+ },
101
+ "scripts": {
102
+ "typecheck": "tsc --build",
103
+ "clean": "rm -rf build-ngc",
104
+ "ng:build": "pnpm run clean && ngc -p tsconfig.angular.json",
105
+ "format": "prettier --write \"src/**/*.{ts,tsx}\""
106
+ }
107
+ }
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/store-review/angular: the Angular entry over the framework-agnostic core.
2
+ // Same reasoning as the React/Vue entries — no per-instance state or event stream to wrap in a
3
+ // service, so this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, requestReview, hasAction } from './store-review';
2
+ export type { IStoreReviewUrlOptions } from './store-review';
@@ -0,0 +1,16 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+
3
+ const EXPO_STORE_REVIEW_MODULE_NAME = 'ExpoStoreReview';
4
+
5
+ // Both methods are optional for the same reason every sibling package's native module type is
6
+ // (device, local-auth, cellular): the native review flow itself is platform-conditional
7
+ // (iOS TestFlight builds, Android below 5.0), so store-review.ts checks for presence itself and
8
+ // falls back to a store-URL open rather than assuming the whole surface is implemented.
9
+ export type INativeStoreReviewModule = {
10
+ isAvailableAsync?(): Promise<boolean>;
11
+ requestReview?(): Promise<void>;
12
+ };
13
+
14
+ export const expoStoreReview = requireNativeModule<INativeStoreReviewModule>(
15
+ EXPO_STORE_REVIEW_MODULE_NAME,
16
+ );
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const FAKE_NATIVE_STORE_REVIEW = {
4
+ isAvailableAsync: vi.fn(async () => true),
5
+ requestReview: vi.fn(async () => undefined),
6
+ };
7
+
8
+ const fakePlatform = { OS: 'ios' as 'ios' | 'android' | 'web' };
9
+
10
+ const fakeLinking = {
11
+ canOpenURL: vi.fn(async () => true),
12
+ openURL: vi.fn(async () => undefined),
13
+ };
14
+
15
+ // The real ExpoStoreReview native module only exists on device — resolving it via
16
+ // requireNativeModule() at import time would throw in this headless test run, so the
17
+ // module-lookup file is faked in place of expo-modules-core's runtime resolution, the same
18
+ // pattern packages/device/src/core/device.test.ts uses.
19
+ vi.mock('./native-module', () => ({
20
+ expoStoreReview: FAKE_NATIVE_STORE_REVIEW,
21
+ }));
22
+
23
+ // expo-modules-core's real entry transitively imports 'react-native' for Platform/
24
+ // TurboModuleRegistry, whose Flow-typed source Vitest's Oxc transform can't parse — same fake
25
+ // packages/application/src/core/application.test.ts uses.
26
+ vi.mock('expo-modules-core', () => ({
27
+ Platform: fakePlatform,
28
+ }));
29
+
30
+ // react-native's own Linking is only reached by the URL-fallback branch (no native review flow
31
+ // available) — stubbed the same way packages/system-ui/src/core/system-ui.test.ts stubs
32
+ // react-native's Platform/processColor.
33
+ vi.mock('react-native', () => ({
34
+ Linking: fakeLinking,
35
+ }));
36
+
37
+ const { isAvailableAsync, requestReview, hasAction } = await import('./store-review');
38
+
39
+ afterEach(() => {
40
+ fakePlatform.OS = 'ios';
41
+ FAKE_NATIVE_STORE_REVIEW.isAvailableAsync = vi.fn(async () => true);
42
+ FAKE_NATIVE_STORE_REVIEW.requestReview = vi.fn(async () => undefined);
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ describe('isAvailableAsync', () => {
47
+ it('delegates to the native module', async () => {
48
+ await expect(isAvailableAsync()).resolves.toBe(true);
49
+ });
50
+
51
+ it('resolves false instead of throwing when the native method is absent', async () => {
52
+ // @ts-expect-error -- simulating a platform where the native module has no such method
53
+ FAKE_NATIVE_STORE_REVIEW.isAvailableAsync = undefined;
54
+
55
+ await expect(isAvailableAsync()).resolves.toBe(false);
56
+ });
57
+ });
58
+
59
+ describe('requestReview — native review path', () => {
60
+ it('calls the native requestReview and never touches Linking', async () => {
61
+ await requestReview({ iosAppStoreUrl: 'https://apps.apple.com/app/id123' });
62
+
63
+ expect(FAKE_NATIVE_STORE_REVIEW.requestReview).toHaveBeenCalled();
64
+ expect(fakeLinking.canOpenURL).not.toHaveBeenCalled();
65
+ expect(fakeLinking.openURL).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe('requestReview — URL-fallback path (no native review flow)', () => {
70
+ it('opens the supplied iOS App Store URL when Linking can open it', async () => {
71
+ // @ts-expect-error -- simulating a platform with no native review flow
72
+ FAKE_NATIVE_STORE_REVIEW.requestReview = undefined;
73
+
74
+ await requestReview({ iosAppStoreUrl: 'https://apps.apple.com/app/id123' });
75
+
76
+ expect(fakeLinking.canOpenURL).toHaveBeenCalledWith('https://apps.apple.com/app/id123');
77
+ expect(fakeLinking.openURL).toHaveBeenCalledWith('https://apps.apple.com/app/id123');
78
+ });
79
+
80
+ it('opens the supplied Android Play Store URL on android', async () => {
81
+ // @ts-expect-error -- simulating a platform with no native review flow
82
+ FAKE_NATIVE_STORE_REVIEW.requestReview = undefined;
83
+ fakePlatform.OS = 'android';
84
+
85
+ await requestReview({
86
+ androidPlayStoreUrl: 'https://play.google.com/store/apps/details?id=com.app',
87
+ });
88
+
89
+ expect(fakeLinking.canOpenURL).toHaveBeenCalledWith(
90
+ 'https://play.google.com/store/apps/details?id=com.app',
91
+ );
92
+ expect(fakeLinking.openURL).toHaveBeenCalledWith(
93
+ 'https://play.google.com/store/apps/details?id=com.app',
94
+ );
95
+ });
96
+
97
+ it('warns and skips openURL when Linking reports the URL cannot be opened', async () => {
98
+ // @ts-expect-error -- simulating a platform with no native review flow
99
+ FAKE_NATIVE_STORE_REVIEW.requestReview = undefined;
100
+ fakeLinking.canOpenURL.mockResolvedValueOnce(false);
101
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
102
+
103
+ await requestReview({ iosAppStoreUrl: 'https://apps.apple.com/app/id123' });
104
+
105
+ expect(fakeLinking.openURL).not.toHaveBeenCalled();
106
+ expect(warnSpy).toHaveBeenCalledWith(
107
+ "StoreReview.requestReview(): Can't open store url: https://apps.apple.com/app/id123",
108
+ );
109
+
110
+ warnSpy.mockRestore();
111
+ });
112
+
113
+ it('warns and never touches Linking when no URL was provided', async () => {
114
+ // @ts-expect-error -- simulating a platform with no native review flow
115
+ FAKE_NATIVE_STORE_REVIEW.requestReview = undefined;
116
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
117
+
118
+ await requestReview();
119
+
120
+ expect(fakeLinking.canOpenURL).not.toHaveBeenCalled();
121
+ expect(fakeLinking.openURL).not.toHaveBeenCalled();
122
+ expect(warnSpy).toHaveBeenCalledWith(
123
+ expect.stringContaining('no native review flow and no store URL was provided'),
124
+ );
125
+
126
+ warnSpy.mockRestore();
127
+ });
128
+ });
129
+
130
+ describe('hasAction', () => {
131
+ it('resolves true when a store URL was supplied, without checking native availability', async () => {
132
+ // @ts-expect-error -- simulating a platform with no native review flow
133
+ FAKE_NATIVE_STORE_REVIEW.isAvailableAsync = undefined;
134
+
135
+ await expect(hasAction({ iosAppStoreUrl: 'https://apps.apple.com/app/id123' })).resolves.toBe(
136
+ true,
137
+ );
138
+ });
139
+
140
+ it('falls back to native availability when no store URL was supplied', async () => {
141
+ await expect(hasAction()).resolves.toBe(true);
142
+ });
143
+
144
+ it('resolves false when there is neither a store URL nor a native review flow', async () => {
145
+ // @ts-expect-error -- simulating a platform with no native review flow
146
+ FAKE_NATIVE_STORE_REVIEW.isAvailableAsync = undefined;
147
+
148
+ await expect(hasAction()).resolves.toBe(false);
149
+ });
150
+ });
@@ -0,0 +1,78 @@
1
+ // Hand-ported from .vendors/expo/packages/expo-store-review/src/StoreReview.ts (sdk-57), with
2
+ // one deliberate deviation: upstream's `storeUrl()` reads
3
+ // `Constants.expoConfig.ios.appStoreUrl`/`.android.playStoreUrl` off `expo-constants`, which this
4
+ // project doesn't depend on (symbiote-expo-package-catalog skill, row 9 — expo-constants hard-
5
+ // depends on an Expo-CLI-generated manifest a bare/Metro-only project like this one never
6
+ // produces). So the store URL is supplied explicitly by the caller instead of being read from a
7
+ // manifest — every function below takes an optional `IStoreReviewUrlOptions` in place of
8
+ // upstream's implicit `Constants` read.
9
+ import { Platform } from 'expo-modules-core';
10
+ import { Linking } from 'react-native';
11
+
12
+ import { expoStoreReview } from './native-module';
13
+
14
+ export type IStoreReviewUrlOptions = {
15
+ iosAppStoreUrl?: string;
16
+ androidPlayStoreUrl?: string;
17
+ };
18
+
19
+ /**
20
+ * Determines if the platform has the capabilities to use `requestReview()`'s native flow.
21
+ * @return
22
+ * - On iOS, resolves to `true` unless the app is distributed through TestFlight.
23
+ * - On Android, resolves to `true` when the Play Store app is installed — upstream's own JSDoc
24
+ * claims "Android 5.0+", but its `StoreReviewModule.kt` checks only for that package.
25
+ *
26
+ * A `true` from either platform still does not mean a prompt will appear.
27
+ */
28
+ export async function isAvailableAsync(): Promise<boolean> {
29
+ return expoStoreReview.isAvailableAsync?.() ?? false;
30
+ }
31
+
32
+ function urlFor(options?: IStoreReviewUrlOptions): string | null {
33
+ if (Platform.OS === 'ios') {
34
+ return options?.iosAppStoreUrl ?? null;
35
+ }
36
+ if (Platform.OS === 'android') {
37
+ return options?.androidPlayStoreUrl ?? null;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * In ideal circumstances this opens a native modal letting the user pick a star rating that's
44
+ * then applied to the App Store/Play Store, without leaving the app. If the native flow is
45
+ * unavailable, falls back to opening the store URL supplied via `options`.
46
+ *
47
+ * Resolving does NOT mean a prompt appeared — neither store reports that back, by design, so
48
+ * there is nothing to branch on. On Android it only shows for a build installed from Google
49
+ * Play; a sideloaded build runs the whole Play Core flow and displays nothing. Both stores also
50
+ * enforce a quota.
51
+ */
52
+ export async function requestReview(options?: IStoreReviewUrlOptions): Promise<void> {
53
+ if (expoStoreReview.requestReview) {
54
+ return expoStoreReview.requestReview();
55
+ }
56
+ const url = urlFor(options);
57
+ if (url) {
58
+ const supported = await Linking.canOpenURL(url);
59
+ if (!supported) {
60
+ console.warn(`StoreReview.requestReview(): Can't open store url: ${url}`);
61
+ } else {
62
+ await Linking.openURL(url);
63
+ }
64
+ } else {
65
+ console.warn(
66
+ 'StoreReview.requestReview(): no native review flow and no store URL was provided — ' +
67
+ 'pass { iosAppStoreUrl, androidPlayStoreUrl } to requestReview().',
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * @return `true` if `requestReview()` is capable of directing the user to some kind of store
74
+ * review flow — either the native flow is available, or a store URL was supplied via `options`.
75
+ */
76
+ export async function hasAction(options?: IStoreReviewUrlOptions): Promise<boolean> {
77
+ return !!urlFor(options) || (await isAvailableAsync());
78
+ }
@@ -0,0 +1,5 @@
1
+ // @symbiote-native/store-review/react: the React entry over the framework-agnostic core.
2
+ // Upstream ships free async functions, no per-instance state and no event stream — there is
3
+ // nothing for a hook to wrap, so this is a plain re-export, mirroring
4
+ // @symbiote-native/device's/@symbiote-native/local-auth's React entry.
5
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/store-review/vue: the Vue entry over the framework-agnostic core. Same
2
+ // reasoning as the React entry — no per-instance state or event stream to wire onto Vue's
3
+ // reactivity, so this is a plain re-export.
4
+ export * from '../core';