expo-ai-composer 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.
Files changed (41) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +469 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/expo/modules/expoaicomposer/AiComposerView.kt +527 -0
  6. package/android/src/main/java/expo/modules/expoaicomposer/AiComposerWrapper.kt +674 -0
  7. package/android/src/main/java/expo/modules/expoaicomposer/ExpoAiComposerModule.kt +96 -0
  8. package/android/src/main/java/expo/modules/expoaicomposer/ImeKeyboardAnimationController.kt +144 -0
  9. package/android/src/main/java/expo/modules/expoaicomposer/PinToTopRunway.kt +74 -0
  10. package/android/src/main/java/expo/modules/expoaicomposer/ScrollToBottomButtonController.kt +170 -0
  11. package/build/AiComposerView.d.ts +4 -0
  12. package/build/AiComposerView.d.ts.map +1 -0
  13. package/build/AiComposerView.js +73 -0
  14. package/build/AiComposerView.js.map +1 -0
  15. package/build/AiComposerWrapper.d.ts +30 -0
  16. package/build/AiComposerWrapper.d.ts.map +1 -0
  17. package/build/AiComposerWrapper.js +9 -0
  18. package/build/AiComposerWrapper.js.map +1 -0
  19. package/build/ExpoAiComposer.types.d.ts +117 -0
  20. package/build/ExpoAiComposer.types.d.ts.map +1 -0
  21. package/build/ExpoAiComposer.types.js +2 -0
  22. package/build/ExpoAiComposer.types.js.map +1 -0
  23. package/build/ExpoAiComposerModule.d.ts +11 -0
  24. package/build/ExpoAiComposerModule.d.ts.map +1 -0
  25. package/build/ExpoAiComposerModule.js +9 -0
  26. package/build/ExpoAiComposerModule.js.map +1 -0
  27. package/build/index.d.ts +5 -0
  28. package/build/index.d.ts.map +1 -0
  29. package/build/index.js +7 -0
  30. package/build/index.js.map +1 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/AiComposerView.swift +616 -0
  33. package/ios/AiComposerWrapper.swift +320 -0
  34. package/ios/ComposerHeightCoordinator.swift +35 -0
  35. package/ios/ExpoAiComposer.podspec +23 -0
  36. package/ios/ExpoAiComposerModule.swift +102 -0
  37. package/ios/KeyboardAwareScrollHandler.swift +601 -0
  38. package/ios/ScrollToBottomButtonController.swift +138 -0
  39. package/ios/ViewHierarchyFinder.swift +35 -0
  40. package/ios/WrapperPropertyObservers.swift +32 -0
  41. package/package.json +72 -0
package/LICENSE ADDED
@@ -0,0 +1,34 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oguzhan Cakmak
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
+ 1. The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ 2. Attribution Requirement: Any project, product, or distribution that uses
16
+ this Software or substantial portions of it must include a visible
17
+ attribution to the original repository in at least one of the following
18
+ locations: the project's README, documentation, about page, or credits
19
+ section. The attribution must include:
20
+
21
+ - The name "expo-ai-composer"
22
+ - A link to https://github.com/muratcakmak/expo-ai-chat
23
+
24
+ Example attribution:
25
+
26
+ Built with [expo-ai-composer](https://github.com/muratcakmak/expo-ai-chat)
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,469 @@
1
+ # expo-ai-composer
2
+
3
+ A native keyboard-aware AI composer component for React Native. Provides smooth, system-level keyboard animations and a ChatGPT-style pin-to-top scroll experience for chat UIs.
4
+
5
+ <table>
6
+ <tr>
7
+ <td width="50%" align="center">
8
+ <img src="./assets/demo-1.gif" width="280" alt="First message animation and streaming" />
9
+ <br/><b>First message + streaming</b>
10
+ </td>
11
+ <td width="50%" align="center">
12
+ <img src="./assets/demo-2.gif" width="280" alt="Pin-to-top and keyboard handling" />
13
+ <br/><b>Pin-to-top + keyboard</b>
14
+ </td>
15
+ </tr>
16
+ </table>
17
+
18
+ ## Highlights
19
+
20
+ <table>
21
+ <tr>
22
+ <td width="50%">
23
+
24
+ ### Keyboard Tracking
25
+ Native keyboard animations that match system apps like iMessage. No JS bridge lag.
26
+
27
+ </td>
28
+ <td width="50%">
29
+
30
+ ### Pin-to-Top Scroll
31
+ New messages pin to the top of the viewport. Streaming responses grow below with a runway — no jarring scroll jumps.
32
+
33
+ </td>
34
+ </tr>
35
+ <tr>
36
+ <td width="50%">
37
+
38
+ ### First Message Animation
39
+ The first message slides from the composer to the top of the screen with a spring animation, just like ChatGPT and v0.
40
+
41
+ </td>
42
+ <td width="50%">
43
+
44
+ ### Streaming + Stop
45
+ Built-in send/stop button with haptic feedback. The stop button appears during streaming with a single prop toggle.
46
+
47
+ </td>
48
+ </tr>
49
+ </table>
50
+
51
+ ## Features
52
+
53
+ - **Native keyboard tracking** — pixel-perfect animations using iOS keyboard notifications and Android `WindowInsetsAnimationCompat`
54
+ - **Pin-to-top scroll** — ChatGPT-style: user message pins to top, response streams below with runway inset
55
+ - **First message animation** — native pin automatically skips the first send so you can implement a slide-from-bottom animation in JS
56
+ - **Auto-growing text input** — multiline input that grows between configurable `minHeight` and `maxHeight`
57
+ - **Send/Stop button** — built-in circular send arrow and square stop icon with haptic feedback
58
+ - **Scroll-to-bottom FAB** — floating button appears when scrolled away, animates in sync with keyboard
59
+ - **Customizable accessory slots** — `headerAccessory`, `leadingAccessory`, `trailingAccessory`, `footerAccessory`
60
+ - **Transparent background** — no hardcoded colors; style from React Native
61
+ - **Expanded editor** — full-screen text editor sheet when maxHeight is reached (iOS)
62
+ - **Imperative ref** — `focus()`, `blur()`, `clear()` via React ref
63
+ - **Cross-platform** — full native implementations on both iOS and Android
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ npx expo install expo-ai-composer
69
+ ```
70
+
71
+ Requires Expo SDK 55+ with the [Expo Modules](https://docs.expo.dev/modules/overview/) system.
72
+
73
+ ## Quick Start
74
+
75
+ ```tsx
76
+ import { useState } from "react";
77
+ import { View, ScrollView } from "react-native";
78
+ import { AiComposer, AiComposerWrapper, constants } from "expo-ai-composer";
79
+
80
+ export default function ChatScreen() {
81
+ const [composerHeight, setComposerHeight] = useState(constants.defaultMinHeight);
82
+ const [isStreaming, setIsStreaming] = useState(false);
83
+
84
+ const handleSend = (text: string) => {
85
+ // Add user message, start streaming AI response...
86
+ setIsStreaming(true);
87
+ };
88
+
89
+ return (
90
+ <View style={{ flex: 1 }}>
91
+ <AiComposerWrapper
92
+ style={{ flex: 1 }}
93
+ pinToTopEnabled
94
+ extraBottomInset={composerHeight}
95
+ >
96
+ <ScrollView style={{ flex: 1 }}>
97
+ {/* Your chat messages */}
98
+ </ScrollView>
99
+
100
+ <View style={{
101
+ position: "absolute",
102
+ bottom: 0, left: 0, right: 0,
103
+ height: composerHeight,
104
+ }}>
105
+ <AiComposer
106
+ style={{ flex: 1 }}
107
+ placeholder="Ask anything"
108
+ onSend={handleSend}
109
+ onStop={() => setIsStreaming(false)}
110
+ onHeightChange={setComposerHeight}
111
+ isStreaming={isStreaming}
112
+ sendButtonEnabled
113
+ />
114
+ </View>
115
+ </AiComposerWrapper>
116
+ </View>
117
+ );
118
+ }
119
+ ```
120
+
121
+ ## API Reference
122
+
123
+ ### `<AiComposer />`
124
+
125
+ The native text input with send/stop button.
126
+
127
+ #### Props
128
+
129
+ | Prop | Type | Default | Description |
130
+ |------|------|---------|-------------|
131
+ | `placeholder` | `string` | `"Type a message..."` | Placeholder text |
132
+ | `text` | `string` | — | Controlled text value |
133
+ | `minHeight` | `number` | `48` | Minimum composer height (points/dp) |
134
+ | `maxHeight` | `number` | `120` | Maximum height before scrolling |
135
+ | `sendButtonEnabled` | `boolean` | `true` | Whether send button is enabled |
136
+ | `showSendButton` | `boolean` | `true` | Show/hide the send button entirely |
137
+ | `editable` | `boolean` | `true` | Whether input is editable |
138
+ | `autoFocus` | `boolean` | `false` | Focus input on mount |
139
+ | `isStreaming` | `boolean` | `false` | Show stop button instead of send |
140
+ | `expandedEditorEnabled` | `boolean` | `false` | Enable full-screen editor (iOS only) |
141
+
142
+ #### Callbacks
143
+
144
+ | Callback | Type | Description |
145
+ |----------|------|-------------|
146
+ | `onChangeText` | `(text: string) => void` | Text changed |
147
+ | `onSend` | `(text: string) => void` | Send button pressed |
148
+ | `onStop` | `() => void` | Stop button pressed |
149
+ | `onHeightChange` | `(height: number) => void` | Composer height changed (for `extraBottomInset`) |
150
+ | `onKeyboardHeightChange` | `(height: number) => void` | Keyboard height changed |
151
+ | `onComposerFocus` | `() => void` | Input gained focus |
152
+ | `onComposerBlur` | `() => void` | Input lost focus |
153
+
154
+ #### Accessory Slots
155
+
156
+ Customize the area around the native text input with React Native views:
157
+
158
+ ```tsx
159
+ <AiComposer
160
+ headerAccessory={<FormattingToolbar />}
161
+ leadingAccessory={<AttachmentButton />}
162
+ trailingAccessory={<CustomSendButton />}
163
+ footerAccessory={<ModelSelector />}
164
+ />
165
+ ```
166
+
167
+ | Slot | Position | Notes |
168
+ |------|----------|-------|
169
+ | `headerAccessory` | Above the input row | Formatting toolbar, context chips |
170
+ | `leadingAccessory` | Left of the input | Attachment, camera, mic button |
171
+ | `trailingAccessory` | Right of the input | **Replaces** built-in send button |
172
+ | `footerAccessory` | Below the input row | Model selector, file previews |
173
+
174
+ <!-- TODO: Replace with actual screenshot -->
175
+ <!-- ![Accessory Slots](./assets/slots-diagram.png) -->
176
+
177
+ ```
178
+ ┌─────────────────────────────────┐
179
+ │ headerAccessory │
180
+ ├──────┬──────────────────┬───────┤
181
+ │lead- │ │trail- │
182
+ │ing │ Native Input │ing │
183
+ │ │ │ │
184
+ ├──────┴──────────────────┴───────┤
185
+ │ footerAccessory │
186
+ └─────────────────────────────────┘
187
+ ```
188
+
189
+ #### Ref Methods
190
+
191
+ ```tsx
192
+ import { useRef } from "react";
193
+ import { AiComposer, type AiComposerRef } from "expo-ai-composer";
194
+
195
+ const composerRef = useRef<AiComposerRef>(null);
196
+
197
+ // Programmatic control
198
+ composerRef.current?.focus(); // Focus the input
199
+ composerRef.current?.blur(); // Blur the input
200
+ composerRef.current?.clear(); // Clear all text
201
+
202
+ <AiComposer ref={composerRef} ... />
203
+ ```
204
+
205
+ ---
206
+
207
+ ### `<AiComposerWrapper />`
208
+
209
+ Keyboard-aware container that manages scroll position, keyboard animations, and composer translation. Wrap your `ScrollView` and `AiComposer` together inside this component.
210
+
211
+ #### Props
212
+
213
+ | Prop | Type | Default | Description |
214
+ |------|------|---------|-------------|
215
+ | `pinToTopEnabled` | `boolean` | `false` | Enable ChatGPT-style pin-to-top on send |
216
+ | `extraBottomInset` | `number` | `0` | Composer height — pass from `onHeightChange` |
217
+ | `extraTopInset` | `number` | `0` | Extra top inset for transparent headers (Android) |
218
+ | `scrollToTopTrigger` | `number` | `0` | Manually trigger pin (use `Date.now()` or counter) |
219
+ | `children` | `ReactNode` | — | Must contain a ScrollView and the composer |
220
+ | `style` | `ViewStyle` | — | Container style |
221
+
222
+ #### Keyboard Behavior
223
+
224
+ | Scenario | Behavior |
225
+ |----------|----------|
226
+ | Keyboard opens while at bottom | Auto-scrolls to keep content visible |
227
+ | Keyboard opens while mid-scroll | Opens over content, no scroll change |
228
+ | User sends message (2nd+) | Message pins to top, response streams below |
229
+ | User sends first message | Native pin skipped — implement JS animation |
230
+ | User scrolls away from bottom | Scroll-to-bottom FAB appears |
231
+ | User drags scroll view down quickly | Keyboard dismisses (interactive) |
232
+
233
+ ---
234
+
235
+ ### `constants`
236
+
237
+ Native constants exported from the module:
238
+
239
+ ```tsx
240
+ import { constants } from "expo-ai-composer";
241
+
242
+ constants.defaultMinHeight; // 48 — default minimum composer height
243
+ constants.defaultMaxHeight; // 120 — default maximum composer height
244
+ constants.contentGap; // 0 — gap between content and composer
245
+ ```
246
+
247
+ ## Layout Guide
248
+
249
+ ### Recommended Structure
250
+
251
+ ```tsx
252
+ <View style={{ flex: 1 }}>
253
+ {/* Optional: Header above the wrapper */}
254
+ <Header />
255
+
256
+ <AiComposerWrapper
257
+ style={{ flex: 1 }}
258
+ pinToTopEnabled
259
+ extraBottomInset={composerHeight}
260
+ >
261
+ {/* ScrollView fills available space */}
262
+ <ScrollView
263
+ style={{ flex: 1 }}
264
+ contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16 }}
265
+ >
266
+ {messages.map(renderMessage)}
267
+ </ScrollView>
268
+
269
+ {/* Composer is absolutely positioned — native code handles translation */}
270
+ <View
271
+ style={{
272
+ position: "absolute",
273
+ bottom: 0, left: 0, right: 0,
274
+ height: composerHeight,
275
+ }}
276
+ pointerEvents="box-none"
277
+ >
278
+ <View style={{ paddingHorizontal: 16, flex: 1 }}>
279
+ <View style={{
280
+ borderRadius: 24,
281
+ overflow: "hidden",
282
+ backgroundColor: "#F2F2F7",
283
+ flex: 1,
284
+ }}>
285
+ <AiComposer
286
+ style={{ flex: 1 }}
287
+ placeholder="Ask anything"
288
+ onSend={handleSend}
289
+ onStop={handleStop}
290
+ onHeightChange={setComposerHeight}
291
+ minHeight={constants.defaultMinHeight}
292
+ maxHeight={constants.defaultMaxHeight}
293
+ isStreaming={isStreaming}
294
+ sendButtonEnabled
295
+ />
296
+ </View>
297
+ </View>
298
+ </View>
299
+ </AiComposerWrapper>
300
+ </View>
301
+ ```
302
+
303
+ ### Important Layout Rules
304
+
305
+ 1. **No `paddingBottom` on scroll content** — the native wrapper handles bottom spacing via `extraBottomInset`
306
+ 2. **Composer uses `height: composerHeight`** — track via `onHeightChange` callback
307
+ 3. **Safe area is handled natively** — don't wrap the composer in `SafeAreaView`
308
+ 4. **Use `pointerEvents="box-none"`** on the composer container to allow scroll touches to pass through
309
+
310
+ ## First Message Animation
311
+
312
+ The native pin-to-top automatically skips the first send. This lets you implement a smooth slide-from-bottom animation in JavaScript:
313
+
314
+ ```tsx
315
+ import { useRef, useEffect } from "react";
316
+ import { Animated, useWindowDimensions } from "react-native";
317
+
318
+ function FirstMessageAnimated({
319
+ children,
320
+ isFirst,
321
+ role,
322
+ }: {
323
+ children: React.ReactNode;
324
+ isFirst: boolean;
325
+ role: "user" | "assistant";
326
+ }) {
327
+ const { height } = useWindowDimensions();
328
+ const translateY = useRef(
329
+ new Animated.Value(isFirst && role === "user" ? height * 0.6 : 0)
330
+ ).current;
331
+ const opacity = useRef(new Animated.Value(isFirst ? 0 : 1)).current;
332
+
333
+ useEffect(() => {
334
+ if (!isFirst) return;
335
+
336
+ if (role === "user") {
337
+ // Slide from bottom to top + fade in
338
+ Animated.parallel([
339
+ Animated.spring(translateY, {
340
+ toValue: 0,
341
+ damping: 20,
342
+ stiffness: 180,
343
+ mass: 1,
344
+ useNativeDriver: true,
345
+ }),
346
+ Animated.timing(opacity, {
347
+ toValue: 1,
348
+ duration: 300,
349
+ useNativeDriver: true,
350
+ }),
351
+ ]).start();
352
+ } else {
353
+ // Assistant: staggered fade in
354
+ Animated.timing(opacity, {
355
+ toValue: 1,
356
+ duration: 350,
357
+ delay: 200,
358
+ useNativeDriver: true,
359
+ }).start();
360
+ }
361
+ }, []);
362
+
363
+ if (!isFirst) return <>{children}</>;
364
+
365
+ return (
366
+ <Animated.View style={{ transform: [{ translateY }], opacity }}>
367
+ {children}
368
+ </Animated.View>
369
+ );
370
+ }
371
+ ```
372
+
373
+ ## How It Works
374
+
375
+ ### Architecture
376
+
377
+ ```
378
+ ┌─────────────────────────────────────────┐
379
+ │ AiComposerWrapper │
380
+ │ (native: manages keyboard + scroll) │
381
+ │ │
382
+ │ ┌───────────────────────────────────┐ │
383
+ │ │ ScrollView │ │
384
+ │ │ (content insets managed natively)│ │
385
+ │ │ │ │
386
+ │ │ ┌─────────────────────────────┐ │ │
387
+ │ │ │ Chat Messages │ │ │
388
+ │ │ └─────────────────────────────┘ │ │
389
+ │ │ │ │
390
+ │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┐ │ │
391
+ │ │ │ Runway (pin-to-top inset) │ │ │
392
+ │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘ │ │
393
+ │ └───────────────────────────────────┘ │
394
+ │ │
395
+ │ ┌───────────────────────────────────┐ │
396
+ │ │ AiComposer │ │
397
+ │ │ (native: translated by keyboard) │ │
398
+ │ └───────────────────────────────────┘ │
399
+ │ │
400
+ │ [FAB] Scroll-to-bottom button │
401
+ └─────────────────────────────────────────┘
402
+ ```
403
+
404
+ ### Pin-to-Top State Machine (iOS)
405
+
406
+ ```
407
+ idle → armed → animating → pinned(enforce) → idle
408
+
409
+ deferred (keyboard still closing)
410
+
411
+ pinned(enforce)
412
+ ```
413
+
414
+ 1. **`idle`** — no pin active
415
+ 2. **`armed`** — send triggered, waiting for content to grow
416
+ 3. **`deferred`** — content grew but keyboard is still animating closed
417
+ 4. **`animating`** — scroll animation to pinned offset in progress
418
+ 5. **`pinned(enforce)`** — locked at offset, runway consumed as content streams
419
+
420
+ ### Keyboard Animation Sync
421
+
422
+ | Platform | Mechanism | Sync Target |
423
+ |----------|-----------|-------------|
424
+ | iOS | `keyboardWillShow/Hide` notifications | `UIView.animate` with system curve |
425
+ | Android | `WindowInsetsAnimationCompat.Callback` | Frame-by-frame inset interpolation |
426
+
427
+ Both platforms translate the composer container and update scroll view padding in the same animation frame as the keyboard, producing a seamless native feel.
428
+
429
+ ## Platform Notes
430
+
431
+ ### iOS
432
+ - Uses `UITextView` for multiline input
433
+ - Keyboard curve extracted from notification `userInfo`
434
+ - Pin animation uses `UIViewPropertyAnimator` with velocity-based duration (1800 pts/sec)
435
+ - Expanded editor presents as `.pageSheet` with detent and grab handle
436
+ - `keyboardDismissMode: .interactive` — drag to dismiss
437
+ - Minimum deployment target: **iOS 15.1**
438
+
439
+ ### Android
440
+ - Uses `EditText` with `TextWatcher`
441
+ - Keyboard tracked via `WindowInsetsAnimationCompat` (API 21+, compat)
442
+ - Send/stop buttons drawn with `Canvas` (no image assets needed)
443
+ - Composer translation applied frame-by-frame during IME animation
444
+ - Minimum SDK: **24**
445
+
446
+ ## Types
447
+
448
+ ```typescript
449
+ import type {
450
+ AiComposerProps,
451
+ AiComposerRef,
452
+ AiComposerViewProps,
453
+ AiComposerConstants,
454
+ AiComposerWrapperProps,
455
+ TextEventPayload,
456
+ HeightEventPayload,
457
+ } from "expo-ai-composer";
458
+ ```
459
+
460
+ ## Requirements
461
+
462
+ - Expo SDK 55+
463
+ - React Native 0.83+
464
+ - iOS 15.1+
465
+ - Android SDK 24+
466
+
467
+ ## License
468
+
469
+ MIT
@@ -0,0 +1,39 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.expoaicomposer'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ def useManagedAndroidSdkVersions = false
13
+ if (useManagedAndroidSdkVersions) {
14
+ useDefaultAndroidSdkVersions()
15
+ } else {
16
+ buildscript {
17
+ ext.safeExtGet = { prop, fallback ->
18
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
19
+ }
20
+ }
21
+ project.android {
22
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
23
+ defaultConfig {
24
+ minSdkVersion safeExtGet("minSdkVersion", 24)
25
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
26
+ }
27
+ }
28
+ }
29
+
30
+ android {
31
+ namespace "expo.modules.expoaicomposer"
32
+ defaultConfig {
33
+ versionCode 1
34
+ versionName "0.1.0"
35
+ }
36
+ lintOptions {
37
+ abortOnError false
38
+ }
39
+ }
@@ -0,0 +1 @@
1
+ <manifest/>