
์ด ๊ธ์ โ๋งํฌ ์ ์ฅ ์ฑโ์ ๋ง๋ค๋ฉด์ iOS Share Extension + Android ๊ณต์ ์ธํ ํธ๋ฅผ ๋ถ์ธ ๊ฐ๋ฐ๊ธฐ์ ๋๋ค.
Expo ์ฐ๋ฉด์ โ์ด๊ฑฐ ๋๋ ๊ฒ ๋ง๋โฆ?โ ํ๋ ๋ถ๋ค ๋์์ผ๋ก ์๋๋ค.
๋งํฌ ์ ์ฅ ์ฑ์ ๋ง๋ค๋ค ๋ณด๋ฉด ์ด๋ฐ UX๊ฐ ๊ผญ ํ์ํด์ง๋๋ค.
iOS์์๋ ์ด๊ฑธ Share Extension, Android์์๋ Share Intent๋ก ๊ตฌํํฉ๋๋ค.
์ด ๊ธ์์๋ Expo(Managed + EAS Build) ํ๊ฒฝ์์
expo-share-extension์ผ๋ก ์ปค์คํ
Share UI ๋ง๋ค๊ณ expo-share-intent๋ก ๊ณต์ ์ธํ
ํธ ์ฒ๋ฆฌ๊น์ง ์ค์ ๋ก ๋์ํ๋ ๊ตฌ์กฐ๋ฅผ ์์ ์ฝ๋๋ก ์ ๋ฆฌํฉ๋๋ค.
โ๋ค๋ฅธ ์ฑ์์ ๊ณต์ ํ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์, ์์ ๋ณ๋ ํ๋ก์ธ์ค๋ก ์ฒ๋ฆฌํ๋ iOS ํ์ฅ ์ฑโ
ํน์ง๋ง ์์ฝํ๋ฉด:
๋ ๋ฆฝ ํ๋ก์ธ์ค
๋ฆฌ์์ค ์ ํ
๊ณต์ ์คํ ๋ฆฌ์ง ํ์
๋ฉ์ธ ์ฑ๊ณผ ๋ก๊ทธ์ธ ์ํ/๋ฐ์ดํฐ๋ฅผ ๊ณต์ ํ๋ ค๋ฉด
Expo์์ โ๋ค๋ฅธ ์ฑ โ ์ฐ๋ฆฌ ์ฑโ ๊ณต์ ๋ฅผ ๋ฐ์ผ๋ ค๋ฉด, ๊ธฐ๋ณธ expo-sharing์ผ๋ก๋ ์ ๋ฉ๋๋ค.
๊ณต์ ๋ฌธ์์๋ โ๋ค๋ฅธ ์ฑ์์ ์ฐ๋ฆฌ ์ฑ์ผ๋ก ๊ณต์ ๋ฐ๋ ๊ฑด ์ง์ ์ ํจโ์ด๋ผ๊ณ ๋ฐํ ์์ต๋๋ค.
๊ทธ๋์ ์ปค๋ฎค๋ํฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์๋๋ค.
// package.json (์์)
{
"dependencies": {
"expo-share-extension": "^5.0.0",
"expo-share-intent": "^5.1.0"
}
}
๋ฒ์ ์ ์์์ ๋๋ค. ์ค์ ํ๋ก์ ํธ์์๋ Expo SDK ๋ฒ์ ์ ๋ง๋ ๋ฒ์ ๋งคํธ๋ฆญ์ค๋ฅผ ๊ผญ ํ์ธํ์ธ์.
| ๋ผ์ด๋ธ๋ฌ๋ฆฌ | ํ๋ซํผ | UI ๋ฐฉ์ | UX |
|---|---|---|---|
| expo-share-extension | iOS ์ ์ฉ | โ ๋ณ๋ React Native UI | ๊ณต์ ์ํธ ์์์ ๋ฐ๋ก ์ฒ๋ฆฌ |
| expo-share-intent | iOS + Android (ํ์ง๋ง ์ฐ๋ฆฌ๋ Android์๋ง) | โ ๋ณ๋ UI ์์, ๋ฉ์ธ ์ฑ์ผ๋ก ๋ฅ๋งํฌ | ์ฑ์ด ์ด๋ฆฌ๊ณ ๋์ ์ฒ๋ฆฌ ([GitHub][4]) |
์ด๋ฒ ๊ตฌ์กฐ๋ ์ด๋ ๊ฒ ๊ฐ์ ธ๊ฐ๋๋ค.
iOS
expo-share-extension์ผ๋ก ์ปค์คํ
Share ExtensionAndroid
expo-share-intent๋ก ๊ณต์ ์ธํ
ํธ โ ๋ฉ์ธ ์ฑ ๋ผ์ฐํ
// app.json (์์์ฉ)
{
"expo": {
"name": "MyLinkBox",
"slug": "my-link-box",
"scheme": "mylinkbox",
"ios": {
"bundleIdentifier": "com.mycompany.mylinkbox",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.mycompany.mylinkbox"
],
"keychain-access-groups": [
"ABCDE12345.*"
]
}
},
"android": {
"package": "com.mycompany.mylinkbox",
"intentFilters": [
{
"action": "android.intent.action.SEND",
"category": ["android.intent.category.DEFAULT"],
"data": { "mimeType": "text/plain" }
}
]
},
"plugins": [
[
"expo-share-extension",
{
"activationRules": [
{ "type": "url", "max": 1 },
{ "type": "text" }
],
"backgroundColor": {
"red": 255,
"green": 255,
"blue": 255,
"alpha": 1
},
"appGroupIdentifier": "group.com.mycompany.mylinkbox"
}
],
[
"expo-share-intent",
{
// iOS๋ ์ปค์คํ
Share Extension์ ์ธ ๊ฑฐ๋ผ์ ๋นํ์ฑํ
"disableIOS": true,
"androidIntentFilters": ["text/*"],
"androidMultiIntentFilters": []
}
]
]
}
}
activationRules (expo-share-extension)iOS์ NSExtensionActivationRules๋ฅผ ์ถ์ํํด ๋ ์ต์
์
๋๋ค.
"activationRules": [
{ "type": "url", "max": 1 },
{ "type": "text" }
]
type: "url"
type: "text"
max
appGroupIdentifier"appGroupIdentifier": "group.com.mycompany.mylinkbox"
entitlements.keychain-access-groups"keychain-access-groups": [
"ABCDE12345.*"
]
ABCDE12345.group.com.mycompany.mylinkbox ์ฒ๋ผ ํ ID + ๊ทธ๋ฃน๋ช
ํํ๋ฅผ ๋ ์๋ฐํ ์ฐ๊ธฐ๋ ํฉ๋๋ค.ABCDE12345.* ์ฒ๋ผ ๋จ์ํํ์ต๋๋ค.expo-share-intent์ disableIOSexpo-share-intent๋ ๊ธฐ๋ณธ์ ์ผ๋ก iOS/Android ๋ชจ๋์ ํ์ฅ์ ์ถ๊ฐํ์ง๋ง,
์ฐ๋ฆฌ๋ iOS์์ expo-share-extension์ ๋ฐ๋ก ์ฐ๋ฏ๋ก iOS ์ชฝ์ ๊บผ ์ค๋๋ค.
index.share.js// index.share.js
import { AppRegistry } from 'react-native';
import ShareExtension from './ShareExtension';
// ๐ ์ด๋ฆ์ ๋ฐ๋์ "shareExtension"
AppRegistry.registerComponent('shareExtension', () => ShareExtension);
AppRegistry.registerComponent์ ์ฒซ ๋ฒ์งธ ์ธ์๊ฐ ๋ฐ๋์"shareExtension"์ด์ด์ผ ํฉ๋๋ค.
// ShareExtension.tsx (์์)
import React, { useEffect, useState } from 'react';
import { InitialProps, close } from 'expo-share-extension';
import {
View,
Text,
Button,
ActivityIndicator,
StyleSheet,
TouchableOpacity,
} from 'react-native';
// โโโโโโโโโโโโโโโโโโโโโโโ
// 1. ์์์ฉ ์ ํธ / API
// โโโโโโโโโโโโโโโโโโโโโโโ
async function getAuthTokenFromSecureStore(): Promise<string | null> {
// ์ค์ ์ฑ์์๋ expo-secure-store + accessGroup ์ฌ์ฉ
return 'dummy-token';
}
type Folder = { id: string; name: string };
async function fetchFolderList(token: string): Promise<Folder[]> {
console.log('fetch folder with token', token);
return [
{ id: 'inbox', name: '๐ฅ ์ธ๋ฐ์ค' },
{ id: 'read-later', name: '๋์ค์ ์ฝ๊ธฐ' },
];
}
function extractUrlFromSharedContent(text?: string | null): string | null {
if (!text) return null;
const match = text.match(/https?:\/\/\S+/);
return match ? match[0] : null;
}
function isValidUrl(url: string | null): boolean {
if (!url) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
}
async function saveLinkToServer(params: {
url: string;
folderId: string | null;
token: string;
}) {
console.log('save link', params);
// ์ค์ ์ฑ์์๋ ์ฌ๊ธฐ์ API ํธ์ถ
}
// โโโโโโโโโโโโโโโโโโโโโโโ
// 2. ShareExtension UI
// โโโโโโโโโโโโโโโโโโโโโโโ
type Props = InitialProps;
export default function ShareExtension(props: Props) {
const { url, text } = props;
const [token, setToken] = useState<string | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const sharedUrl = extractUrlFromSharedContent(url || text);
useEffect(() => {
(async () => {
const t = await getAuthTokenFromSecureStore();
setToken(t);
if (!t) return;
const list = await fetchFolderList(t);
setFolders(list);
setSelectedFolder(list[0] ?? null);
})();
}, []);
const handleSave = async () => {
if (!token) return;
if (!isValidUrl(sharedUrl)) return;
try {
setSaving(true);
await saveLinkToServer({
url: sharedUrl!,
folderId: selectedFolder?.id ?? null,
token,
});
setSaved(true);
setTimeout(() => {
close(); // 1.5์ด ํ Share Extension ๋ซ๊ธฐ
}, 1500);
} finally {
setSaving(false);
}
};
if (!token) {
return (
<View style={styles.container}>
<Text style={styles.title}>๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค</Text>
<Text style={styles.subtitle}>
์ฑ์์ ๋จผ์ ๋ก๊ทธ์ธํ ๋ค ๋ค์ ๊ณต์ ํด์ฃผ์ธ์.
</Text>
<Button title="๋ซ๊ธฐ" onPress={close} />
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>๋งํฌ ์ ์ฅํ๊ธฐ</Text>
<Text style={styles.subtitle}>
{isValidUrl(sharedUrl) ? sharedUrl : '์ ํจํ URL์ด ์์ต๋๋ค.'}
</Text>
{/* ํด๋ ์ ํ (์์) */}
<View style={{ marginVertical: 12 }}>
{folders.map((folder) => (
<TouchableOpacity
key={folder.id}
style={[
styles.folderItem,
folder.id === selectedFolder?.id && styles.folderItemSelected,
]}
onPress={() => setSelectedFolder(folder)}
>
<Text>{folder.name}</Text>
</TouchableOpacity>
))}
</View>
{saving ? (
<ActivityIndicator />
) : saved ? (
<Text>์ ์ฅ ์๋ฃ! ๐</Text>
) : (
<Button
title="์ ์ฅํ๊ธฐ"
onPress={handleSave}
disabled={!isValidUrl(sharedUrl)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#fff',
justifyContent: 'flex-start',
},
title: {
fontSize: 18,
fontWeight: '600',
},
subtitle: {
marginTop: 8,
color: '#555',
},
folderItem: {
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ddd',
marginBottom: 8,
},
folderItemSelected: {
borderColor: '#4f46e5',
backgroundColor: '#eef2ff',
},
});
ํ ํฐ ์์ด ๋ค์ด์จ ๊ฒฝ์ฐ
URL ์ถ์ถ
props.url์ด ์๊ณ ํ
์คํธ๋ง ์ฌ ์๋ ์์ผ๋ text์์๋ ์ ๊ท์์ผ๋ก URL ์ถ์ถ์ ์ฅ ํ ์๋ ๋ซ๊ธฐ
close() ํธ์ถ๋ก Share Extension ์ข
๋ฃ์ค์ ์๋น์ค์์๋ โExtension์์๋ ์ธ์ฆ๋ ์ฌ์ฉ์๋ก API๋ฅผ ํธ์ถโํด์ผ ํฉ๋๋ค.
์ด๊ฑธ ํด๊ฒฐํ๊ธฐ ์ํด iOS Keychain ๊ณต์ ๋ฅผ ์๋๋ค.
// secureStore.ts (์์)
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
// ์ค์ ํ๋ก์ ํธ์์๋ ํ ID๊ฐ ํฌํจ๋ ๊ฐ ์ฌ์ฉ ๊ถ์ฅ
const ACCESS_GROUP = 'ABCDE12345.*';
export async function saveAuthToken(token: string) {
await SecureStore.setItemAsync(TOKEN_KEY, token, {
accessGroup: ACCESS_GROUP,
});
}
export async function getAuthToken() {
try {
const token = await SecureStore.getItemAsync(TOKEN_KEY, {
accessGroup: ACCESS_GROUP,
});
return token;
} catch (e) {
console.error('failed to get token', e);
return null;
}
}
โ ๏ธ ์ฃผ์
- ์ค์ ์ฑ์์
keychain-access-groupsentitlement์accessGroup๊ฐ์ด ์๋ก ์ผ๊ด๋๊ฒ ์ค์ ๋์ด์ผ ํฉ๋๋ค.- ํ ID ์ ๋(
ABCDE12345.)๋ฅผ ์ด๋ป๊ฒ ๋ถ์ผ์ง๋ ํ๋ก์ ํธ๋ง๋ค ๋ค๋ฅผ ์ ์์ด์, ์ฌ๊ธฐ์ ์๋๋ง ๋ณด์ด๋ ์์๋ก ์ ์์ต๋๋ค.
expo-share-intentAndroid๋ Share Extension์ด ๋ฐ๋ก ์๊ณ , Intent Filter๋ฅผ ๋งค๋ํ์คํธ(app.json)๋ก ์ค์ ํ ๋ค, expo-share-intent ํ
์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ต๋๋ค.
"android": {
"intentFilters": [
{
"action": "android.intent.action.SEND",
"category": ["android.intent.category.DEFAULT"],
"data": {
"mimeType": "text/plain"
}
}
]
}
// app/_layout.tsx (ํน์ App.tsx)
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent';
export default function RootLayout() {
const router = useRouter();
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
useEffect(() => {
if (Platform.OS !== 'android') return;
if (!hasShareIntent || !shareIntent) return;
const url = shareIntent.webUrl ?? shareIntent.text;
if (!url) return;
// ๊ณต์ ๋ URL์ ์ ์ฅ ํ๋ฉด์ผ๋ก ๋ผ์ฐํ
router.push({
pathname: '/save',
params: { url },
});
resetShareIntent();
}, [hasShareIntent, shareIntent]);
return (
// ... ์ค์ ์ฑ ๋ค๋น๊ฒ์ด์
/๋ ์ด์์
null
);
}
expo-share-intent๋ ๋ค์ดํฐ๋ธ ๋ชจ๋์ด๋ผ Expo Go์์๋ ๋์ํ์ง ์๊ณ ,
๋ฐ๋์ prebuild + dev client / EAS Build ํ๊ฒฝ์์ ํ ์คํธํด์ผ ํฉ๋๋ค.
expo-share-extension์ ์ฐ๋ฉด iOS ํ๊ฒ/Swift ์ฝ๋๋ ํ๋ฌ๊ทธ์ธ์ด ์์์ ์์ฑํด ์ค๋๋ค.
๊ด์ฌ ์๋ ๋ถ๋ถ๋ง ์ถ์ํํด์ ๋ณด๋ฉด ๋๋ต ์ด๋ฐ ๊ตฌ์กฐ์
๋๋ค.
// ShareExtensionViewController.swift (๊ฐ๋
์์)
class ShareExtensionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
loadReactNativeContent()
}
private func loadReactNativeContent() {
getShareData { [weak self] sharedData in
guard let self = self else { return }
let rootView = reactNativeFactory!.rootViewFactory.view(
withModuleName: "shareExtension", // index.share.js์์ ๋ฑ๋กํ ์ด๋ฆ
initialProperties: sharedData
)
self.view.addSubview(rootView)
rootView.frame = self.view.bounds
}
}
private func getShareData(completion: @escaping ([String: Any]?) -> Void) {
guard let items = extensionContext?.inputItems as? [NSExtensionItem] else {
completion(nil)
return
}
var result: [String: Any] = [:]
// URL / ํ
์คํธ ์ถ์ถ (๋จ์ํ ์์)
for item in items {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier("public.url") {
provider.loadItem(forTypeIdentifier: "public.url", options: nil) { value, _ in
if let url = value as? URL {
result["url"] = url.absoluteString
}
completion(result)
}
} else if provider.hasItemConformingToTypeIdentifier("public.text") {
provider.loadItem(forTypeIdentifier: "public.text", options: nil) { value, _ in
if let text = value as? String {
result["text"] = text
}
completion(result)
}
}
}
}
}
func close() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
์ญํ ์ ๋จ์ํฉ๋๋ค.
extensionContext์์ ๊ณต์ ๋ ๋ฐ์ดํฐ ํ์ฑshareExtension ๋ชจ๋์ ๋์ฐ๋ฉฐ initialProperties๋ก ์ ๋ฌclose() ํธ์ถ ์ ๋ค์ดํฐ๋ธ์์ completeRequest๋ก Extension ์ข
๋ฃExpo ๊ณต์ ๋ฌธ์์ ๋ฐ๋ฅด๋ฉด, EAS Build๊ฐ app extension ํ๊ฒ์ ์ ๋๋ก ์ธ์ํ๊ฒ ํ๋ ค๋ฉด
extra.eas.build.experimental.ios.appExtensions ์ค์ ์ ๊ถ์ฅํฉ๋๋ค.
{
"expo": {
"extra": {
"eas": {
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"targetName": "MyShareExtension",
"bundleIdentifier": "com.mycompany.mylinkbox.ShareExtension",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.mycompany.mylinkbox"
]
}
}
]
}
}
}
}
}
}
}
์ด ์ค์ ์ด ์์ผ๋ฉด EAS๊ฐ
์ง๋ฌธ ์ฃผ์ จ๋ โํ๋ฆฐ ๋ด์ฉ์ด ์๋์งโ ๊ธฐ์ค์ผ๋ก ์ ๋ฆฌํ๋ฉด:
expo-share-extension ์ฌ์ฉ ๋ฐฉ์
activationRules, appGroupIdentifier, index.share.js์์ "shareExtension" ๋ฑ๋ก ๋ฑ์expo-share-intent ์ฌ์ฉ ๋ฐฉ์
useShareIntent ํ
์ฌ์ฉ, disableIOS, androidIntentFilters ์ค์ ๋ชจ๋โApp Group์ ํตํ ๋ฐ์ดํฐ ๊ณต์ โ๋ผ๋ ์ค๋ช
iOS์์ ๋ฉ์ธ ์ฑ/Share Extension ์ฌ์ด ๋ฐ์ดํฐ ๊ณต์ ๋
EAS Build + appExtensions ์ค์
extra.eas.build.experimental.ios.appExtensions๋ฅผ ํตํดํ ํฐ ๊ณต์ ์ฝ๋์ ๋ํ ํ ๊ฐ์ง ์ฃผ์
accessGroup: 'ABCDE12345.*'์ฒ๋ผ ๋จ์ํํ์ง๋ง,๋งํฌ ๋๋ผํผ๋ ๋จ์ํ ์ ์ฅ ํด์ด ์๋๋๋ค.
์ ๋ฆฌํ๊ณ , ๋ค์ ๊บผ๋ด๋ณด๊ฒ ๋ง๋๋ ๋งํฌ ๊ด๋ฆฌ ๋๊ตฌ๋ฅผ ์งํฅํ๊ณ ์์ด์.
๐ ๋น ๋ฅด๊ณ ๊ฐํธํ ๋งํฌ ์ ์ฅ
iOS/Android ์ฑ, ์น, ํฌ๋กฌ ์ต์คํ
์
์ด๋์๋ ๋ฐ๋ก ์ ์ฅ
๐ง ํด๋๋ณ๋ก ๊น๋ํ๊ฒ ์ ๋ฆฌ
์ฝ์ ๊ฑฐ๋ฆฌ, ๋ ํผ๋ฐ์ค, ์ผํ ํ๋ณด๊น์ง ์ฃผ์ ๋ณ ์ ๋ฆฌ
๐ ํด๋๋ฅผ ์น๊ตฌ์๊ฒ ๊ณต์
๊ฐ์ด ๋ณด๋ ์๋ฃ๋ ํด๋ ๋จ์๋ก ๋งํฌ ํ ๋ฒ์ ๊ณต์
โก ํฌ๋กฌ ์ต์คํ
์
์ํด๋ฆญ ์ ์ฅ
์ง๊ธ ๋ณด๊ณ ์๋ ํ์ด์ง๋ฅผ ๋ฒํผ ํ ๋ฒ์ผ๋ก ์ ์ฅ
๐ ๋งํฌ ๋๋ผํผ ์ฑ ๋ค์ด๋ก๋ (iOS / Android)
๐ ๋งํฌ ๋๋ผํผ ์น์์ ์ฌ์ฉํ๋ฌ ๊ฐ๊ธฐ
๐ ํฌ๋กฌ ์น์คํ ์ด์์ ์ต์คํ
์
์ค์นํ๊ธฐ