@microsoft/power-apps-native-host 0.2.22
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 +112 -0
- package/README.md +59 -0
- package/app.plugin.js +4 -0
- package/lib/NOTICE.txt +1659 -0
- package/lib/commonjs/ErrorBoundary.js +128 -0
- package/lib/commonjs/auth/AuthContext.js +281 -0
- package/lib/commonjs/bridge/NativePowerAppsBridge.js +279 -0
- package/lib/commonjs/bridge/setupNativeHost.js +65 -0
- package/lib/commonjs/connections/ConnectionSetupScreen.js +531 -0
- package/lib/commonjs/connections/disambiguate.js +88 -0
- package/lib/commonjs/connections/types.js +58 -0
- package/lib/commonjs/connections/useConnectionSetup.js +236 -0
- package/lib/commonjs/controls/FilePicker.js +183 -0
- package/lib/commonjs/controls/ImagePicker.js +236 -0
- package/lib/commonjs/index.js +246 -0
- package/lib/commonjs/logger.js +27 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/platform/PowerPlatformConnectionApi.js +606 -0
- package/lib/commonjs/platform/PowerPlatformEnvironmentApi.js +194 -0
- package/lib/commonjs/platform/resolveConnections.js +264 -0
- package/lib/commonjs/platform/useConnectionRefs.js +270 -0
- package/lib/commonjs/provider/PowerAppsHostProvider.js +336 -0
- package/lib/commonjs/provider/PowerAppsProvider.js +351 -0
- package/lib/commonjs/telemetry/index.js +70 -0
- package/lib/commonjs/theme/ThemeProvider.js +80 -0
- package/lib/commonjs/theme/tokens.js +50 -0
- package/lib/commonjs/utils/dataverseUtils.js +98 -0
- package/lib/commonjs/utils/isDevPlayer.js +27 -0
- package/lib/module/ErrorBoundary.js +122 -0
- package/lib/module/auth/AuthContext.js +276 -0
- package/lib/module/bridge/NativePowerAppsBridge.js +273 -0
- package/lib/module/bridge/setupNativeHost.js +62 -0
- package/lib/module/connections/ConnectionSetupScreen.js +526 -0
- package/lib/module/connections/disambiguate.js +83 -0
- package/lib/module/connections/types.js +60 -0
- package/lib/module/connections/useConnectionSetup.js +233 -0
- package/lib/module/controls/FilePicker.js +178 -0
- package/lib/module/controls/ImagePicker.js +231 -0
- package/lib/module/index.js +41 -0
- package/lib/module/logger.js +22 -0
- package/lib/module/package.json +1 -0
- package/lib/module/platform/PowerPlatformConnectionApi.js +595 -0
- package/lib/module/platform/PowerPlatformEnvironmentApi.js +186 -0
- package/lib/module/platform/resolveConnections.js +259 -0
- package/lib/module/platform/useConnectionRefs.js +267 -0
- package/lib/module/provider/PowerAppsHostProvider.js +332 -0
- package/lib/module/provider/PowerAppsProvider.js +345 -0
- package/lib/module/telemetry/index.js +55 -0
- package/lib/module/theme/ThemeProvider.js +63 -0
- package/lib/module/theme/tokens.js +46 -0
- package/lib/module/utils/dataverseUtils.js +90 -0
- package/lib/module/utils/isDevPlayer.js +23 -0
- package/lib/typescript/commonjs/ErrorBoundary.d.ts +30 -0
- package/lib/typescript/commonjs/auth/AuthContext.d.ts +57 -0
- package/lib/typescript/commonjs/bridge/NativePowerAppsBridge.d.ts +112 -0
- package/lib/typescript/commonjs/bridge/setupNativeHost.d.ts +39 -0
- package/lib/typescript/commonjs/connections/ConnectionSetupScreen.d.ts +107 -0
- package/lib/typescript/commonjs/connections/disambiguate.d.ts +43 -0
- package/lib/typescript/commonjs/connections/types.d.ts +91 -0
- package/lib/typescript/commonjs/connections/useConnectionSetup.d.ts +96 -0
- package/lib/typescript/commonjs/controls/FilePicker.d.ts +65 -0
- package/lib/typescript/commonjs/controls/ImagePicker.d.ts +76 -0
- package/lib/typescript/commonjs/index.d.ts +42 -0
- package/lib/typescript/commonjs/logger.d.ts +11 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/platform/PowerPlatformConnectionApi.d.ts +205 -0
- package/lib/typescript/commonjs/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
- package/lib/typescript/commonjs/platform/resolveConnections.d.ts +86 -0
- package/lib/typescript/commonjs/platform/useConnectionRefs.d.ts +126 -0
- package/lib/typescript/commonjs/provider/PowerAppsHostProvider.d.ts +123 -0
- package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +148 -0
- package/lib/typescript/commonjs/telemetry/index.d.ts +48 -0
- package/lib/typescript/commonjs/theme/ThemeProvider.d.ts +30 -0
- package/lib/typescript/commonjs/theme/tokens.d.ts +27 -0
- package/lib/typescript/commonjs/utils/dataverseUtils.d.ts +39 -0
- package/lib/typescript/commonjs/utils/isDevPlayer.d.ts +19 -0
- package/lib/typescript/module/ErrorBoundary.d.ts +30 -0
- package/lib/typescript/module/auth/AuthContext.d.ts +57 -0
- package/lib/typescript/module/bridge/NativePowerAppsBridge.d.ts +112 -0
- package/lib/typescript/module/bridge/setupNativeHost.d.ts +39 -0
- package/lib/typescript/module/connections/ConnectionSetupScreen.d.ts +107 -0
- package/lib/typescript/module/connections/disambiguate.d.ts +43 -0
- package/lib/typescript/module/connections/types.d.ts +91 -0
- package/lib/typescript/module/connections/useConnectionSetup.d.ts +96 -0
- package/lib/typescript/module/controls/FilePicker.d.ts +65 -0
- package/lib/typescript/module/controls/ImagePicker.d.ts +76 -0
- package/lib/typescript/module/index.d.ts +42 -0
- package/lib/typescript/module/logger.d.ts +11 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/platform/PowerPlatformConnectionApi.d.ts +205 -0
- package/lib/typescript/module/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
- package/lib/typescript/module/platform/resolveConnections.d.ts +86 -0
- package/lib/typescript/module/platform/useConnectionRefs.d.ts +126 -0
- package/lib/typescript/module/provider/PowerAppsHostProvider.d.ts +123 -0
- package/lib/typescript/module/provider/PowerAppsProvider.d.ts +148 -0
- package/lib/typescript/module/telemetry/index.d.ts +48 -0
- package/lib/typescript/module/theme/ThemeProvider.d.ts +30 -0
- package/lib/typescript/module/theme/tokens.d.ts +27 -0
- package/lib/typescript/module/utils/dataverseUtils.d.ts +39 -0
- package/lib/typescript/module/utils/isDevPlayer.d.ts +19 -0
- package/package.json +119 -0
- package/scripts/build-ios-base.js +96 -0
- package/scripts/bundle.js +95 -0
- package/scripts/generate-connector-schemas.js +75 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*!
|
|
4
|
+
* power-apps-native-host — connection disambiguation types
|
|
5
|
+
*
|
|
6
|
+
* Defines the native-host-specific types for connection resolution. The web
|
|
7
|
+
* host equivalents live in @microsoft/power-apps-host, but the native host
|
|
8
|
+
* implements a lighter version that does not depend on IConnectionService.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolved authorization state of a connection instance.
|
|
13
|
+
*
|
|
14
|
+
* - `Connected` — platform status is Connected AND the ISV appid is in the connection ACL.
|
|
15
|
+
* - `NeedsAclGrant` — platform status is Connected but the ISV appid is missing from the ACL.
|
|
16
|
+
* - `NotConnected` — platform status is anything other than Connected.
|
|
17
|
+
*/
|
|
18
|
+
export let ConnectionStatus = /*#__PURE__*/function (ConnectionStatus) {
|
|
19
|
+
ConnectionStatus["Connected"] = "Connected";
|
|
20
|
+
ConnectionStatus["NeedsAclGrant"] = "NeedsAclGrant";
|
|
21
|
+
ConnectionStatus["NotConnected"] = "NotConnected";
|
|
22
|
+
return ConnectionStatus;
|
|
23
|
+
}({});
|
|
24
|
+
|
|
25
|
+
/** A single connection instance returned by the Power Platform connectivity API. */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Result of attempting to match a single connection reference to an instance.
|
|
29
|
+
*
|
|
30
|
+
* - `resolved` — exactly one Connected connection found; `connectionId` is set.
|
|
31
|
+
* - `missing` — no connections found for this connector in the user's environment.
|
|
32
|
+
* - `ambiguous` — multiple Connected connections found; the app must pick one.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Disambiguation result for a single connection reference (one apiId, possibly
|
|
37
|
+
* mapped to multiple data source names in the final connections map).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Callback invoked when a connection reference has multiple Connected candidate
|
|
42
|
+
* connections. The consuming app should present a native picker UI (e.g. a
|
|
43
|
+
* bottom sheet) and return the selected connection ID.
|
|
44
|
+
*
|
|
45
|
+
* Return `null` to leave the connection unresolved (the SDK will receive an
|
|
46
|
+
* empty `connectionName` and data operations for that connector will fail).
|
|
47
|
+
*
|
|
48
|
+
* @param context.localRefId — local reference key from power.config.json
|
|
49
|
+
* @param context.apiId — connector API identifier
|
|
50
|
+
* @param context.candidates — list of Connected connection instances to choose from
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Callback invoked when the user has no connection for a required connector.
|
|
55
|
+
*
|
|
56
|
+
* The consuming app can redirect to the Power Apps maker portal or show an
|
|
57
|
+
* in-app connection setup flow. Return `true` to indicate the user has
|
|
58
|
+
* created a connection and the hook should retry; return `false` to leave the
|
|
59
|
+
* connection unresolved.
|
|
60
|
+
*/
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*!
|
|
4
|
+
* power-apps-native-host — useConnectionSetup
|
|
5
|
+
*
|
|
6
|
+
* A hook that drives the "create missing connection" flow for a single connector.
|
|
7
|
+
*
|
|
8
|
+
* Usage pattern (in the ConnectionSetupScreen):
|
|
9
|
+
*
|
|
10
|
+
* const setup = useConnectionSetup({
|
|
11
|
+
* connectorId: 'shared_office365users',
|
|
12
|
+
* environmentId,
|
|
13
|
+
* region,
|
|
14
|
+
* acquireToken,
|
|
15
|
+
* onConnected: (connectionId) => { ... mark resolved, advance to next ... }
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Render setup.consentUrl in a WebView when setup.phase === 'oauth'
|
|
19
|
+
* // Call setup.startCreate() to trigger connection creation
|
|
20
|
+
* // Call setup.checkNow() to re-poll after WebView finishes
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useState, useCallback, useRef } from 'react';
|
|
24
|
+
import { createConnection, pollConnectionStatus, confirmConsentCode, addAadAppToConnectionAcl, parseJwtOid, SERVICE_POWERAPPS_SCOPE } from "../platform/PowerPlatformConnectionApi.js";
|
|
25
|
+
import { powerPlatformScope } from "../platform/PowerPlatformEnvironmentApi.js";
|
|
26
|
+
import { extractConnectionId } from "../connections/disambiguate.js";
|
|
27
|
+
import { debugWarn } from "../logger.js";
|
|
28
|
+
import { getAppLogger, HostOperationNames } from "../telemetry/index.js";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Phase machine
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The current phase of the connection setup flow for one connector.
|
|
36
|
+
*
|
|
37
|
+
* idle — not yet started.
|
|
38
|
+
* creating — POST in progress (showing a spinner).
|
|
39
|
+
* oauth — connection created; OAuth consent URL is ready (show WebView).
|
|
40
|
+
* confirming — WebView dismissed; polling for Connected status.
|
|
41
|
+
* connected — connection is Connected; ready to proceed.
|
|
42
|
+
* error — unrecoverable error; `errorMessage` is set.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Hook
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export function useConnectionSetup({
|
|
50
|
+
connectorId,
|
|
51
|
+
environmentId,
|
|
52
|
+
region = 'prod',
|
|
53
|
+
acquireToken,
|
|
54
|
+
onConnected,
|
|
55
|
+
isvClientId,
|
|
56
|
+
oauthRedirectUrl,
|
|
57
|
+
polling
|
|
58
|
+
}) {
|
|
59
|
+
const [phase, setPhase] = useState('idle');
|
|
60
|
+
const [consentUrl, setConsentUrl] = useState(null);
|
|
61
|
+
const [errorMessage, setErrorMessage] = useState(null);
|
|
62
|
+
|
|
63
|
+
// Track the connection name across re-renders without triggering re-renders.
|
|
64
|
+
const connectionNameRef = useRef(null);
|
|
65
|
+
// Prevent concurrent startCreate / checkNow calls.
|
|
66
|
+
const inFlightRef = useRef(false);
|
|
67
|
+
const handleConnected = useCallback(async instance => {
|
|
68
|
+
setPhase('connected');
|
|
69
|
+
setConsentUrl(null);
|
|
70
|
+
|
|
71
|
+
// The RP auto-writes the user as Owner when a connection is created.
|
|
72
|
+
// However, APIM also checks that the requesting appid is present in the
|
|
73
|
+
// ACE's AppIds list. For third-party ISV tokens this is never populated
|
|
74
|
+
// automatically — we must explicitly add the ISV's client ID to the
|
|
75
|
+
// connection ACL so that APIM accepts tokens bearing that appid at runtime.
|
|
76
|
+
if (isvClientId) {
|
|
77
|
+
const paToken = await acquireToken([SERVICE_POWERAPPS_SCOPE]);
|
|
78
|
+
const userOid = paToken ? parseJwtOid(paToken) : null;
|
|
79
|
+
if (!paToken || !userOid) {
|
|
80
|
+
setPhase('error');
|
|
81
|
+
setErrorMessage('Connection was created, but access could not be granted to this app. Please try again.');
|
|
82
|
+
debugWarn('[useConnectionSetup] could not grant connection ACL: missing Power Apps token or user OID');
|
|
83
|
+
getAppLogger().trackErrorEvent(HostOperationNames.PlatformConnectionAclGrant, {
|
|
84
|
+
phase: 'setupConnectionAcl',
|
|
85
|
+
reason: !paToken ? 'missingPowerAppsToken' : 'missingUserOid'
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const aclGranted = await addAadAppToConnectionAcl(connectorId, instance.id, environmentId, isvClientId, userOid, paToken);
|
|
90
|
+
if (!aclGranted) {
|
|
91
|
+
setPhase('error');
|
|
92
|
+
setErrorMessage('Connection was created, but access could not be granted to this app. Please try again.');
|
|
93
|
+
getAppLogger().trackErrorEvent(HostOperationNames.PlatformConnectionAclGrant, {
|
|
94
|
+
phase: 'setupConnectionAcl',
|
|
95
|
+
reason: 'aclGrantRejected'
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
onConnected?.(instance);
|
|
101
|
+
}, [onConnected, acquireToken, connectorId, environmentId, isvClientId]);
|
|
102
|
+
const startCreate = useCallback(async () => {
|
|
103
|
+
if (inFlightRef.current) return;
|
|
104
|
+
inFlightRef.current = true;
|
|
105
|
+
setPhase('creating');
|
|
106
|
+
setErrorMessage(null);
|
|
107
|
+
setConsentUrl(null);
|
|
108
|
+
connectionNameRef.current = null;
|
|
109
|
+
try {
|
|
110
|
+
const token = await acquireToken([powerPlatformScope(region)]);
|
|
111
|
+
if (!token) {
|
|
112
|
+
setPhase('error');
|
|
113
|
+
setErrorMessage('Failed to acquire authentication token');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const result = await createConnection(connectorId, environmentId, region, token, undefined, oauthRedirectUrl);
|
|
117
|
+
if (result.status === 'error') {
|
|
118
|
+
setPhase('error');
|
|
119
|
+
setErrorMessage(result.message);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
connectionNameRef.current = result.connectionName;
|
|
123
|
+
if (result.status === 'connected') {
|
|
124
|
+
// Connection is already Connected — poll once to get the full instance.
|
|
125
|
+
const instance = await pollConnectionStatus(connectorId, result.connectionName, environmentId, region, acquireToken, {
|
|
126
|
+
timeoutMs: 10_000,
|
|
127
|
+
...polling
|
|
128
|
+
});
|
|
129
|
+
if (instance) {
|
|
130
|
+
handleConnected(instance);
|
|
131
|
+
} else {
|
|
132
|
+
// Already created but not Connected yet — treat as oauth phase without a URL.
|
|
133
|
+
setPhase('oauth');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// status === 'pending': show the OAuth URL in a WebView.
|
|
139
|
+
setConsentUrl(result.consentLink);
|
|
140
|
+
setPhase('oauth');
|
|
141
|
+
|
|
142
|
+
// Start background polling immediately — the user may finish OAuth quickly.
|
|
143
|
+
const instance = await pollConnectionStatus(connectorId, result.connectionName, environmentId, region, acquireToken, polling);
|
|
144
|
+
|
|
145
|
+
// If the phase changed while we were polling (e.g., user dismissed WebView
|
|
146
|
+
// and checkNow already resolved it), don't override.
|
|
147
|
+
if (instance && phase !== 'connected') {
|
|
148
|
+
handleConnected(instance);
|
|
149
|
+
} else if (!instance && phase !== 'connected') {
|
|
150
|
+
setPhase('error');
|
|
151
|
+
setErrorMessage('Connection setup timed out. Please try again.');
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
inFlightRef.current = false;
|
|
155
|
+
}
|
|
156
|
+
}, [connectorId, environmentId, region, acquireToken, polling, handleConnected, phase]);
|
|
157
|
+
const checkNow = useCallback(async () => {
|
|
158
|
+
const name = connectionNameRef.current;
|
|
159
|
+
if (!name || inFlightRef.current) return;
|
|
160
|
+
if (phase !== 'oauth' && phase !== 'confirming') return;
|
|
161
|
+
inFlightRef.current = true;
|
|
162
|
+
setPhase('confirming');
|
|
163
|
+
try {
|
|
164
|
+
const instance = await pollConnectionStatus(connectorId, name, environmentId, region, acquireToken, {
|
|
165
|
+
intervalMs: 1000,
|
|
166
|
+
timeoutMs: 30_000,
|
|
167
|
+
...polling
|
|
168
|
+
});
|
|
169
|
+
if (instance) {
|
|
170
|
+
handleConnected(instance);
|
|
171
|
+
} else {
|
|
172
|
+
// Not yet connected — go back to oauth so the user can retry.
|
|
173
|
+
setPhase('oauth');
|
|
174
|
+
setErrorMessage('Connection is not yet active. Complete the sign-in and try again.');
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
inFlightRef.current = false;
|
|
178
|
+
}
|
|
179
|
+
}, [phase, environmentId, region, acquireToken, polling, handleConnected]);
|
|
180
|
+
const reset = useCallback(() => {
|
|
181
|
+
inFlightRef.current = false;
|
|
182
|
+
connectionNameRef.current = null;
|
|
183
|
+
setPhase('idle');
|
|
184
|
+
setConsentUrl(null);
|
|
185
|
+
setErrorMessage(null);
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Call after the in-app browser session closes.
|
|
190
|
+
*
|
|
191
|
+
* If a CommitTokenCode `code` is provided (from the redirect URL `?code=`
|
|
192
|
+
* param), POST it to `confirmConsentCode` first so APIM can activate the
|
|
193
|
+
* connection. Then poll until Connected regardless of whether the confirm
|
|
194
|
+
* call succeeded (the connection may already be Connected server-side).
|
|
195
|
+
*/
|
|
196
|
+
const completeConsent = useCallback(async code => {
|
|
197
|
+
const name = connectionNameRef.current;
|
|
198
|
+
if (!name || inFlightRef.current) return;
|
|
199
|
+
inFlightRef.current = true;
|
|
200
|
+
setPhase('confirming');
|
|
201
|
+
try {
|
|
202
|
+
if (code) {
|
|
203
|
+
const token = await acquireToken([powerPlatformScope(region)]);
|
|
204
|
+
if (token) {
|
|
205
|
+
const connectionId = extractConnectionId(name);
|
|
206
|
+
await confirmConsentCode(connectorId, connectionId, environmentId, region, token, code);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const instance = await pollConnectionStatus(connectorId, name, environmentId, region, acquireToken, {
|
|
210
|
+
intervalMs: 1000,
|
|
211
|
+
timeoutMs: 30_000,
|
|
212
|
+
...polling
|
|
213
|
+
});
|
|
214
|
+
if (instance) {
|
|
215
|
+
handleConnected(instance);
|
|
216
|
+
} else {
|
|
217
|
+
setPhase('oauth');
|
|
218
|
+
setErrorMessage('Connection is not yet active. Complete the sign-in and try again.');
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
inFlightRef.current = false;
|
|
222
|
+
}
|
|
223
|
+
}, [connectorId, environmentId, region, acquireToken, polling, handleConnected]);
|
|
224
|
+
return {
|
|
225
|
+
phase,
|
|
226
|
+
consentUrl,
|
|
227
|
+
errorMessage,
|
|
228
|
+
startCreate,
|
|
229
|
+
checkNow,
|
|
230
|
+
completeConsent,
|
|
231
|
+
reset
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { View, Text, TouchableOpacity, ActivityIndicator, Alert, StyleSheet } from 'react-native';
|
|
5
|
+
import * as DocumentPicker from 'expo-document-picker';
|
|
6
|
+
import * as Sharing from 'expo-sharing';
|
|
7
|
+
import { File as ExpoFile, Paths } from 'expo-file-system';
|
|
8
|
+
import { Feather } from '@expo/vector-icons';
|
|
9
|
+
import { useThemeTokens } from "../theme/ThemeProvider.js";
|
|
10
|
+
import { createDataverseUploadFile, readFileAsUint8Array } from "../utils/dataverseUtils.js";
|
|
11
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
|
+
/**
|
|
13
|
+
* A unified file component for view and edit scenarios.
|
|
14
|
+
*
|
|
15
|
+
* - Pass `onDownload` alone for a read-only row that opens the share sheet on tap.
|
|
16
|
+
* - Pass `onChange` alone for a pick-only button (e.g. in an edit form).
|
|
17
|
+
* - Pass both to show the existing file name (tappable to download) alongside a
|
|
18
|
+
* replace button — useful when the edit screen should also allow previewing.
|
|
19
|
+
*/
|
|
20
|
+
export function FilePicker({
|
|
21
|
+
label,
|
|
22
|
+
fileName,
|
|
23
|
+
onDownload,
|
|
24
|
+
shareTitle,
|
|
25
|
+
onChange,
|
|
26
|
+
acceptedTypes = ['*/*'],
|
|
27
|
+
pickButtonLabel = 'Select File',
|
|
28
|
+
replaceButtonLabel = 'Replace',
|
|
29
|
+
readingLabel = 'Reading file...',
|
|
30
|
+
containerStyle,
|
|
31
|
+
labelStyle,
|
|
32
|
+
fileRowStyle,
|
|
33
|
+
fileNameStyle,
|
|
34
|
+
buttonStyle,
|
|
35
|
+
buttonTextStyle,
|
|
36
|
+
accentColor
|
|
37
|
+
}) {
|
|
38
|
+
const theme = useThemeTokens();
|
|
39
|
+
const accent = accentColor ?? theme.accentBase;
|
|
40
|
+
const [downloading, setDownloading] = useState(false);
|
|
41
|
+
const [pickedName, setPickedName] = useState(null);
|
|
42
|
+
const [reading, setReading] = useState(false);
|
|
43
|
+
const bg = theme.surface0;
|
|
44
|
+
const labelColor = theme.text2;
|
|
45
|
+
const displayName = pickedName ?? fileName;
|
|
46
|
+
const download = async () => {
|
|
47
|
+
if (!onDownload) return;
|
|
48
|
+
setDownloading(true);
|
|
49
|
+
try {
|
|
50
|
+
const bytes = await onDownload();
|
|
51
|
+
const destFile = new ExpoFile(Paths.cache, displayName ?? 'download');
|
|
52
|
+
destFile.write(bytes);
|
|
53
|
+
await Sharing.shareAsync(destFile.uri, {
|
|
54
|
+
dialogTitle: shareTitle ?? displayName ?? 'File'
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
Alert.alert('Download Failed', e instanceof Error ? e.message : String(e));
|
|
58
|
+
} finally {
|
|
59
|
+
setDownloading(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const pick = async () => {
|
|
63
|
+
if (!onChange) return;
|
|
64
|
+
const result = await DocumentPicker.getDocumentAsync({
|
|
65
|
+
type: acceptedTypes,
|
|
66
|
+
copyToCacheDirectory: true
|
|
67
|
+
});
|
|
68
|
+
if (!result.canceled && result.assets.length > 0) {
|
|
69
|
+
const picked = result.assets[0];
|
|
70
|
+
setPickedName(picked.name);
|
|
71
|
+
setReading(true);
|
|
72
|
+
try {
|
|
73
|
+
const bytes = await readFileAsUint8Array(picked.uri);
|
|
74
|
+
const mimeType = picked.mimeType ?? 'application/octet-stream';
|
|
75
|
+
onChange({
|
|
76
|
+
uri: picked.uri,
|
|
77
|
+
name: picked.name,
|
|
78
|
+
mimeType,
|
|
79
|
+
file: createDataverseUploadFile(picked.name, mimeType, bytes),
|
|
80
|
+
bytes
|
|
81
|
+
});
|
|
82
|
+
} finally {
|
|
83
|
+
setReading(false);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
88
|
+
style: [styles.container, containerStyle],
|
|
89
|
+
children: [label ? /*#__PURE__*/_jsx(Text, {
|
|
90
|
+
style: [styles.label, {
|
|
91
|
+
color: labelColor
|
|
92
|
+
}, labelStyle],
|
|
93
|
+
children: label
|
|
94
|
+
}) : null, displayName ? /*#__PURE__*/_jsxs(TouchableOpacity, {
|
|
95
|
+
style: [styles.fileRow, fileRowStyle],
|
|
96
|
+
onPress: onDownload ? download : undefined,
|
|
97
|
+
activeOpacity: onDownload ? 0.7 : 1,
|
|
98
|
+
disabled: downloading,
|
|
99
|
+
children: [/*#__PURE__*/_jsx(Feather, {
|
|
100
|
+
name: "file-text",
|
|
101
|
+
size: 16,
|
|
102
|
+
color: accent
|
|
103
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
104
|
+
style: [styles.fileName, {
|
|
105
|
+
color: accent
|
|
106
|
+
}, fileNameStyle],
|
|
107
|
+
numberOfLines: 1,
|
|
108
|
+
children: displayName
|
|
109
|
+
}), onDownload && (downloading ? /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
110
|
+
size: "small",
|
|
111
|
+
color: accent
|
|
112
|
+
}) : /*#__PURE__*/_jsx(Feather, {
|
|
113
|
+
name: "download",
|
|
114
|
+
size: 16,
|
|
115
|
+
color: accent
|
|
116
|
+
}))]
|
|
117
|
+
}) : null, onChange ? /*#__PURE__*/_jsxs(TouchableOpacity, {
|
|
118
|
+
style: [styles.button, {
|
|
119
|
+
borderColor: accent,
|
|
120
|
+
backgroundColor: bg
|
|
121
|
+
}, buttonStyle],
|
|
122
|
+
onPress: pick,
|
|
123
|
+
activeOpacity: 0.7,
|
|
124
|
+
disabled: reading,
|
|
125
|
+
children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
126
|
+
size: "small",
|
|
127
|
+
color: accent
|
|
128
|
+
}) : /*#__PURE__*/_jsx(Feather, {
|
|
129
|
+
name: displayName ? 'refresh-cw' : 'file-plus',
|
|
130
|
+
size: 16,
|
|
131
|
+
color: accent
|
|
132
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
133
|
+
style: [styles.buttonText, {
|
|
134
|
+
color: accent
|
|
135
|
+
}, buttonTextStyle],
|
|
136
|
+
children: reading ? readingLabel : displayName ? replaceButtonLabel : pickButtonLabel
|
|
137
|
+
})]
|
|
138
|
+
}) : null]
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const styles = StyleSheet.create({
|
|
142
|
+
container: {
|
|
143
|
+
marginBottom: 16
|
|
144
|
+
},
|
|
145
|
+
label: {
|
|
146
|
+
fontSize: 12,
|
|
147
|
+
fontWeight: '600',
|
|
148
|
+
marginBottom: 4
|
|
149
|
+
},
|
|
150
|
+
fileRow: {
|
|
151
|
+
flexDirection: 'row',
|
|
152
|
+
alignItems: 'center',
|
|
153
|
+
gap: 8,
|
|
154
|
+
paddingVertical: 4
|
|
155
|
+
},
|
|
156
|
+
fileName: {
|
|
157
|
+
flex: 1,
|
|
158
|
+
fontSize: 14,
|
|
159
|
+
fontWeight: '600'
|
|
160
|
+
},
|
|
161
|
+
button: {
|
|
162
|
+
flexDirection: 'row',
|
|
163
|
+
alignItems: 'center',
|
|
164
|
+
gap: 8,
|
|
165
|
+
paddingHorizontal: 16,
|
|
166
|
+
paddingVertical: 10,
|
|
167
|
+
borderWidth: 1,
|
|
168
|
+
borderRadius: 8,
|
|
169
|
+
alignSelf: 'flex-start',
|
|
170
|
+
maxWidth: '100%',
|
|
171
|
+
marginTop: 4
|
|
172
|
+
},
|
|
173
|
+
buttonText: {
|
|
174
|
+
fontSize: 14,
|
|
175
|
+
fontWeight: '600',
|
|
176
|
+
flexShrink: 1
|
|
177
|
+
}
|
|
178
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { View, Text, Image, TouchableOpacity, Alert, StyleSheet, ActivityIndicator } from 'react-native';
|
|
5
|
+
import * as ExpoImagePicker from 'expo-image-picker';
|
|
6
|
+
import { Feather } from '@expo/vector-icons';
|
|
7
|
+
import { useThemeTokens } from "../theme/ThemeProvider.js";
|
|
8
|
+
import { base64ToDataUri, base64ToUint8Array, createDataverseUploadFile, detectImageMime } from "../utils/dataverseUtils.js";
|
|
9
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
10
|
+
/**
|
|
11
|
+
* A self-contained image picker that shows a preview and lets the user
|
|
12
|
+
* choose a new image from the library. Works on Android and iOS.
|
|
13
|
+
*
|
|
14
|
+
* - Existing images are seeded from a Dataverse base64 string via `initialBase64`.
|
|
15
|
+
* - On pick, the image is returned as base64 for legacy callers and as a
|
|
16
|
+
* File-compatible object for generated Dataverse upload methods.
|
|
17
|
+
*/
|
|
18
|
+
export function ImagePicker({
|
|
19
|
+
label = 'Image',
|
|
20
|
+
initialBase64,
|
|
21
|
+
onChange,
|
|
22
|
+
containerStyle,
|
|
23
|
+
labelStyle,
|
|
24
|
+
buttonRowStyle,
|
|
25
|
+
buttonStyle,
|
|
26
|
+
buttonTextStyle,
|
|
27
|
+
previewStyle,
|
|
28
|
+
accentColor,
|
|
29
|
+
onImageChange,
|
|
30
|
+
imageFileName = 'image',
|
|
31
|
+
quality = 0.8,
|
|
32
|
+
allowsEditing = false,
|
|
33
|
+
aspect,
|
|
34
|
+
cropShape,
|
|
35
|
+
exif = false,
|
|
36
|
+
legacy = false,
|
|
37
|
+
cameraButtonLabel = 'Take Photo',
|
|
38
|
+
selectButtonLabel = 'Select Image',
|
|
39
|
+
changeButtonLabel = 'Change Image',
|
|
40
|
+
readingLabel = 'Reading image...'
|
|
41
|
+
}) {
|
|
42
|
+
const theme = useThemeTokens();
|
|
43
|
+
const accentColor_resolved = accentColor ?? theme.accentBase;
|
|
44
|
+
const labelColor = theme.text2;
|
|
45
|
+
const bg = theme.surface0;
|
|
46
|
+
const previewBg = theme.surface2;
|
|
47
|
+
|
|
48
|
+
// The URI shown in the preview — either a data: URI built from the seeded
|
|
49
|
+
// base64, or the file:// URI of a newly picked image.
|
|
50
|
+
const [previewUri, setPreviewUri] = useState(null);
|
|
51
|
+
const [reading, setReading] = useState(false);
|
|
52
|
+
const imagePickerOptions = {
|
|
53
|
+
mediaTypes: ['images'],
|
|
54
|
+
quality,
|
|
55
|
+
allowsEditing,
|
|
56
|
+
base64: true,
|
|
57
|
+
exif,
|
|
58
|
+
legacy,
|
|
59
|
+
...(allowsEditing && aspect ? {
|
|
60
|
+
aspect
|
|
61
|
+
} : {}),
|
|
62
|
+
...(allowsEditing && cropShape ? {
|
|
63
|
+
shape: cropShape
|
|
64
|
+
} : {})
|
|
65
|
+
};
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (initialBase64) {
|
|
68
|
+
setPreviewUri(base64ToDataUri(initialBase64));
|
|
69
|
+
} else {
|
|
70
|
+
setPreviewUri(null);
|
|
71
|
+
}
|
|
72
|
+
}, [initialBase64]);
|
|
73
|
+
const handleAsset = asset => {
|
|
74
|
+
const uri = asset.uri;
|
|
75
|
+
const base64 = asset.base64;
|
|
76
|
+
if (!base64) {
|
|
77
|
+
Alert.alert('Unable to read image', 'The selected image did not include image data. Please try again.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setPreviewUri(uri);
|
|
81
|
+
const bytes = base64ToUint8Array(base64);
|
|
82
|
+
const mimeType = detectImageMime(base64);
|
|
83
|
+
const extension = mimeType.slice('image/'.length).replace('jpeg', 'jpg');
|
|
84
|
+
const name = asset.fileName ?? (imageFileName.includes('.') ? imageFileName : `${imageFileName}.${extension}`);
|
|
85
|
+
onChange?.(base64);
|
|
86
|
+
onImageChange?.({
|
|
87
|
+
uri,
|
|
88
|
+
name,
|
|
89
|
+
mimeType,
|
|
90
|
+
base64,
|
|
91
|
+
file: createDataverseUploadFile(name, mimeType, bytes),
|
|
92
|
+
bytes
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const pick = async () => {
|
|
96
|
+
if (reading) return;
|
|
97
|
+
const {
|
|
98
|
+
status
|
|
99
|
+
} = await ExpoImagePicker.requestMediaLibraryPermissionsAsync();
|
|
100
|
+
if (status !== 'granted') {
|
|
101
|
+
Alert.alert('Permission required', 'Please allow access to your photo library.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const result = await ExpoImagePicker.launchImageLibraryAsync(imagePickerOptions);
|
|
105
|
+
if (!result.canceled && result.assets.length > 0) {
|
|
106
|
+
setReading(true);
|
|
107
|
+
try {
|
|
108
|
+
handleAsset(result.assets[0]);
|
|
109
|
+
} finally {
|
|
110
|
+
setReading(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const shoot = async () => {
|
|
115
|
+
if (reading) return;
|
|
116
|
+
const {
|
|
117
|
+
status
|
|
118
|
+
} = await ExpoImagePicker.requestCameraPermissionsAsync();
|
|
119
|
+
if (status !== 'granted') {
|
|
120
|
+
Alert.alert('Permission required', 'Please allow access to your camera.');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const result = await ExpoImagePicker.launchCameraAsync(imagePickerOptions);
|
|
124
|
+
if (!result.canceled && result.assets.length > 0) {
|
|
125
|
+
setReading(true);
|
|
126
|
+
try {
|
|
127
|
+
handleAsset(result.assets[0]);
|
|
128
|
+
} finally {
|
|
129
|
+
setReading(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
134
|
+
style: [styles.container, containerStyle],
|
|
135
|
+
children: [label ? /*#__PURE__*/_jsx(Text, {
|
|
136
|
+
style: [styles.label, {
|
|
137
|
+
color: labelColor
|
|
138
|
+
}, labelStyle],
|
|
139
|
+
children: label
|
|
140
|
+
}) : null, /*#__PURE__*/_jsxs(View, {
|
|
141
|
+
style: [styles.buttonRow, buttonRowStyle],
|
|
142
|
+
children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
|
|
143
|
+
style: [styles.button, {
|
|
144
|
+
borderColor: accentColor_resolved,
|
|
145
|
+
backgroundColor: bg
|
|
146
|
+
}, buttonStyle],
|
|
147
|
+
onPress: shoot,
|
|
148
|
+
activeOpacity: 0.7,
|
|
149
|
+
disabled: reading,
|
|
150
|
+
children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
151
|
+
size: "small",
|
|
152
|
+
color: accentColor_resolved
|
|
153
|
+
}) : /*#__PURE__*/_jsx(Feather, {
|
|
154
|
+
name: "camera",
|
|
155
|
+
size: 16,
|
|
156
|
+
color: accentColor_resolved
|
|
157
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
158
|
+
style: [styles.buttonText, {
|
|
159
|
+
color: accentColor_resolved
|
|
160
|
+
}, buttonTextStyle],
|
|
161
|
+
children: reading ? readingLabel : cameraButtonLabel
|
|
162
|
+
})]
|
|
163
|
+
}), /*#__PURE__*/_jsxs(TouchableOpacity, {
|
|
164
|
+
style: [styles.button, {
|
|
165
|
+
borderColor: accentColor_resolved,
|
|
166
|
+
backgroundColor: bg
|
|
167
|
+
}, buttonStyle],
|
|
168
|
+
onPress: pick,
|
|
169
|
+
activeOpacity: 0.7,
|
|
170
|
+
disabled: reading,
|
|
171
|
+
children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
172
|
+
size: "small",
|
|
173
|
+
color: accentColor_resolved
|
|
174
|
+
}) : /*#__PURE__*/_jsx(Feather, {
|
|
175
|
+
name: "image",
|
|
176
|
+
size: 16,
|
|
177
|
+
color: accentColor_resolved
|
|
178
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
179
|
+
style: [styles.buttonText, {
|
|
180
|
+
color: accentColor_resolved
|
|
181
|
+
}, buttonTextStyle],
|
|
182
|
+
children: reading ? readingLabel : previewUri ? changeButtonLabel : selectButtonLabel
|
|
183
|
+
})]
|
|
184
|
+
})]
|
|
185
|
+
}), previewUri ? /*#__PURE__*/_jsx(Image, {
|
|
186
|
+
source: {
|
|
187
|
+
uri: previewUri
|
|
188
|
+
},
|
|
189
|
+
style: [styles.preview, {
|
|
190
|
+
backgroundColor: previewBg
|
|
191
|
+
}, previewStyle],
|
|
192
|
+
resizeMode: "cover"
|
|
193
|
+
}) : null]
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const styles = StyleSheet.create({
|
|
197
|
+
container: {
|
|
198
|
+
marginBottom: 16
|
|
199
|
+
},
|
|
200
|
+
label: {
|
|
201
|
+
fontSize: 12,
|
|
202
|
+
fontWeight: '600',
|
|
203
|
+
marginBottom: 4
|
|
204
|
+
},
|
|
205
|
+
buttonRow: {
|
|
206
|
+
flexDirection: 'row',
|
|
207
|
+
gap: 8
|
|
208
|
+
},
|
|
209
|
+
button: {
|
|
210
|
+
flexDirection: 'row',
|
|
211
|
+
alignItems: 'center',
|
|
212
|
+
gap: 8,
|
|
213
|
+
paddingHorizontal: 16,
|
|
214
|
+
paddingVertical: 10,
|
|
215
|
+
borderWidth: 1,
|
|
216
|
+
borderRadius: 8,
|
|
217
|
+
alignSelf: 'flex-start',
|
|
218
|
+
maxWidth: '100%'
|
|
219
|
+
},
|
|
220
|
+
buttonText: {
|
|
221
|
+
fontSize: 14,
|
|
222
|
+
fontWeight: '600',
|
|
223
|
+
flexShrink: 1
|
|
224
|
+
},
|
|
225
|
+
preview: {
|
|
226
|
+
width: '100%',
|
|
227
|
+
height: 180,
|
|
228
|
+
borderRadius: 8,
|
|
229
|
+
marginTop: 8
|
|
230
|
+
}
|
|
231
|
+
});
|