๐Ÿ“ฑ Expo๋กœ iOS Share Extension ๊ตฌํ˜„๊ธฐ

LinkDropperยท2025๋…„ 12์›” 11์ผ

Link Dropper

๋ชฉ๋ก ๋ณด๊ธฐ
16/17
post-thumbnail

์ด ๊ธ€์€ โ€œ๋งํฌ ์ €์žฅ ์•ฑโ€์„ ๋งŒ๋“ค๋ฉด์„œ iOS Share Extension + Android ๊ณต์œ  ์ธํ…ํŠธ๋ฅผ ๋ถ™์ธ ๊ฐœ๋ฐœ๊ธฐ์ž…๋‹ˆ๋‹ค.
Expo ์“ฐ๋ฉด์„œ โ€œ์ด๊ฑฐ ๋˜๋Š” ๊ฒŒ ๋งž๋‚˜โ€ฆ?โ€ ํ–ˆ๋˜ ๋ถ„๋“ค ๋Œ€์ƒ์œผ๋กœ ์”๋‹ˆ๋‹ค.


0. ๋ฌธ์ œ์˜์‹: ์™œ ๊ตณ์ด Share Extension๊นŒ์ง€?

๋งํฌ ์ €์žฅ ์•ฑ์„ ๋งŒ๋“ค๋‹ค ๋ณด๋ฉด ์ด๋Ÿฐ UX๊ฐ€ ๊ผญ ํ•„์š”ํ•ด์ง‘๋‹ˆ๋‹ค.

  1. Safari / Chrome์—์„œ ๊ธ€์„ ์ฝ๋‹ค๊ฐ€
  2. ๐Ÿ”˜ ๊ณต์œ  ๋ฒ„ํŠผ โ†’ ๊ณต์œ  ์‹œํŠธ ์—ด๊ณ 
  3. ์šฐ๋ฆฌ ์•ฑ ์•„์ด์ฝ˜์„ ๋ˆ„๋ฅด๋ฉด
  4. ๋ฐ”๋กœ โ€˜๋งํฌ ์ €์žฅโ€™ ํ™”๋ฉด์ด ๋œจ๊ณ , ์•ฑ ์ „ํ™˜ ์—†์ด ์ €์žฅ ๋ โœ…

iOS์—์„œ๋Š” ์ด๊ฑธ Share Extension, Android์—์„œ๋Š” Share Intent๋กœ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

์ด ๊ธ€์—์„œ๋Š” Expo(Managed + EAS Build) ํ™˜๊ฒฝ์—์„œ

  • iOS: expo-share-extension์œผ๋กœ ์ปค์Šคํ…€ Share UI ๋งŒ๋“ค๊ณ 
  • Android: expo-share-intent๋กœ ๊ณต์œ  ์ธํ…ํŠธ ์ฒ˜๋ฆฌ

๊นŒ์ง€ ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋Š” ๊ตฌ์กฐ๋ฅผ ์˜ˆ์‹œ ์ฝ”๋“œ๋กœ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.


1. Share Extension ํ•œ ์ค„ ์ •์˜

โ€œ๋‹ค๋ฅธ ์•ฑ์—์„œ ๊ณต์œ ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„, ์ž‘์€ ๋ณ„๋„ ํ”„๋กœ์„ธ์Šค๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” iOS ํ™•์žฅ ์•ฑโ€

ํŠน์ง•๋งŒ ์š”์•ฝํ•˜๋ฉด:

  1. ๋…๋ฆฝ ํ”„๋กœ์„ธ์Šค

    • ๋ฉ”์ธ ์•ฑ๊ณผ ์™„์ „ํžˆ ๋ถ„๋ฆฌ๋œ Target / ํ”„๋กœ์„ธ์Šค๋กœ ๋Œ์•„๊ฐ
  2. ๋ฆฌ์†Œ์Šค ์ œํ•œ

    • ๋ฉ”๋ชจ๋ฆฌยท์‹œ๊ฐ„ ์ œํ•œ์ด ์žˆ์–ด์„œ, ๋ฌด๊ฑฐ์šด ์ž‘์—…(๋Œ€๋Ÿ‰ ์ด๋ฏธ์ง€ ๋ถ„์„ ๋“ฑ)์€ ๋งค์šฐ ๋น„์ถ”์ฒœ
  3. ๊ณต์œ  ์Šคํ† ๋ฆฌ์ง€ ํ•„์š”

    • ๋ฉ”์ธ ์•ฑ๊ณผ ๋กœ๊ทธ์ธ ์ƒํƒœ/๋ฐ์ดํ„ฐ๋ฅผ ๊ณต์œ ํ•˜๋ ค๋ฉด

      • App Group / Keychain Access Group ๊ฐ™์€ iOS ๋ฉ”์ปค๋‹ˆ์ฆ˜์„ ์จ์•ผ ํ•จ

2. ์–ด๋–ค ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์“ธ ๊ฒƒ์ธ๊ฐ€

Expo์—์„œ โ€œ๋‹ค๋ฅธ ์•ฑ โ†’ ์šฐ๋ฆฌ ์•ฑโ€ ๊ณต์œ ๋ฅผ ๋ฐ›์œผ๋ ค๋ฉด, ๊ธฐ๋ณธ expo-sharing์œผ๋กœ๋Š” ์•ˆ ๋ฉ๋‹ˆ๋‹ค.
๊ณต์‹ ๋ฌธ์„œ์—๋„ โ€œ๋‹ค๋ฅธ ์•ฑ์—์„œ ์šฐ๋ฆฌ ์•ฑ์œผ๋กœ ๊ณต์œ  ๋ฐ›๋Š” ๊ฑด ์ง€์› ์•ˆ ํ•จโ€์ด๋ผ๊ณ  ๋ฐ•ํ˜€ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ž˜์„œ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์”๋‹ˆ๋‹ค.

2-1. ์‚ฌ์šฉํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

// package.json (์˜ˆ์‹œ)
{
  "dependencies": {
    "expo-share-extension": "^5.0.0",
    "expo-share-intent": "^5.1.0"
  }
}

๋ฒ„์ „์€ ์˜ˆ์‹œ์ž…๋‹ˆ๋‹ค. ์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” Expo SDK ๋ฒ„์ „์— ๋งž๋Š” ๋ฒ„์ „ ๋งคํŠธ๋ฆญ์Šค๋ฅผ ๊ผญ ํ™•์ธํ•˜์„ธ์š”.

2-2. ์—ญํ•  ๋ถ„๋‹ด

๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌํ”Œ๋žซํผUI ๋ฐฉ์‹UX
expo-share-extensioniOS ์ „์šฉโœ… ๋ณ„๋„ React Native UI๊ณต์œ  ์‹œํŠธ ์•ˆ์—์„œ ๋ฐ”๋กœ ์ฒ˜๋ฆฌ
expo-share-intentiOS + Android (ํ•˜์ง€๋งŒ ์šฐ๋ฆฌ๋Š” Android์—๋งŒ)โŒ ๋ณ„๋„ UI ์—†์Œ, ๋ฉ”์ธ ์•ฑ์œผ๋กœ ๋”ฅ๋งํฌ์•ฑ์ด ์—ด๋ฆฌ๊ณ  ๋‚˜์„œ ์ฒ˜๋ฆฌ ([GitHub][4])

์ด๋ฒˆ ๊ตฌ์กฐ๋Š” ์ด๋ ‡๊ฒŒ ๊ฐ€์ ธ๊ฐ‘๋‹ˆ๋‹ค.

  • iOS

    • expo-share-extension์œผ๋กœ ์ปค์Šคํ…€ Share Extension
  • Android

    • expo-share-intent๋กœ ๊ณต์œ  ์ธํ…ํŠธ โ†’ ๋ฉ”์ธ ์•ฑ ๋ผ์šฐํŒ…

3. app.json / app.config ์„ค์ •

3-1. ๊ธฐ๋ณธ Expo ์„ค์ • (์˜ˆ์‹œ)

// 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": []
        }
      ]
    ]
  }
}

3-2. ํ•ต์‹ฌ ์˜ต์…˜ ํ•ด์„ค

๐Ÿ”ธ activationRules (expo-share-extension)

iOS์˜ NSExtensionActivationRules๋ฅผ ์ถ”์ƒํ™”ํ•ด ๋‘” ์˜ต์…˜์ž…๋‹ˆ๋‹ค.

"activationRules": [
  { "type": "url", "max": 1 },
  { "type": "text" }
]
  • type: "url"

    • Safari์—์„œ ํŽ˜์ด์ง€ ๊ณต์œ  ์‹œ, URL์ด ์žˆ์„ ๋•Œ ํ™œ์„ฑํ™”
  • type: "text"

    • ์„ ํƒํ•œ ํ…์ŠคํŠธ ๊ณต์œ ์—๋„ ๋ฐ˜์‘ (ํ…์ŠคํŠธ ์•ˆ์— URL์ด ์„ž์ธ ์ผ€์ด์Šค๊นŒ์ง€ ์ผ€์–ดํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์œ ์šฉ)
  • max

    • ๋™์‹œ์— ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ํ•ญ๋ชฉ ๊ฐœ์ˆ˜ (์ง€์ • ์•ˆ ํ•˜๋ฉด ๊ธฐ๋ณธ๊ฐ’ 1)

๐Ÿ”ธ appGroupIdentifier

"appGroupIdentifier": "group.com.mycompany.mylinkbox"
  • ๋ฉ”์ธ ์•ฑ / Share Extension์ด ๊ฐ™์ด ์“ฐ๋Š” App Group ID
  • ์ด ๊ทธ๋ฃน ์•ˆ์˜ ๊ณต์œ  ์ปจํ…Œ์ด๋„ˆ์— ํŒŒ์ผยท๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ™์ด ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”ธ entitlements.keychain-access-groups

"keychain-access-groups": [
  "ABCDE12345.*"
]
  • iOS Keychain ๊ณต์œ ๋ฅผ ์œ„ํ•œ ๊ทธ๋ฃน
  • ์‹ค์ œ๋กœ๋Š” ABCDE12345.group.com.mycompany.mylinkbox ์ฒ˜๋Ÿผ ํŒ€ ID + ๊ทธ๋ฃน๋ช… ํ˜•ํƒœ๋ฅผ ๋” ์—„๋ฐ€ํžˆ ์“ฐ๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.
  • ๊ธ€์—์„œ๋Š” ์˜ˆ์‹œ๋ผ ABCDE12345.* ์ฒ˜๋Ÿผ ๋‹จ์ˆœํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”ธ expo-share-intent์˜ disableIOS

expo-share-intent๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ iOS/Android ๋ชจ๋‘์— ํ™•์žฅ์„ ์ถ”๊ฐ€ํ•˜์ง€๋งŒ,
์šฐ๋ฆฌ๋Š” iOS์—์„œ expo-share-extension์„ ๋”ฐ๋กœ ์“ฐ๋ฏ€๋กœ iOS ์ชฝ์€ ๊บผ ์ค๋‹ˆ๋‹ค.


4. iOS Share Extension UI ๋งŒ๋“ค๊ธฐ

4-1. ์—”ํŠธ๋ฆฌ ํฌ์ธํŠธ: index.share.js

// index.share.js
import { AppRegistry } from 'react-native';
import ShareExtension from './ShareExtension';

// ๐Ÿ‘‡ ์ด๋ฆ„์€ ๋ฐ˜๋“œ์‹œ "shareExtension"
AppRegistry.registerComponent('shareExtension', () => ShareExtension);

AppRegistry.registerComponent์˜ ์ฒซ ๋ฒˆ์งธ ์ธ์ž๊ฐ€ ๋ฐ˜๋“œ์‹œ "shareExtension" ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

4-2. 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',
  },
});

4-3. UX ํ•ต์‹ฌ ํฌ์ธํŠธ

  1. ํ† ํฐ ์—†์ด ๋“ค์–ด์˜จ ๊ฒฝ์šฐ

    • โ€œ๋กœ๊ทธ์ธ ํ•„์š”โ€ ๋ฉ”์‹œ์ง€ + ๋‹ซ๊ธฐ ๋ฒ„ํŠผ๋งŒ ๋ณด์—ฌ์คŒ
  2. URL ์ถ”์ถœ

    • props.url์ด ์—†๊ณ  ํ…์ŠคํŠธ๋งŒ ์˜ฌ ์ˆ˜๋„ ์žˆ์œผ๋‹ˆ text์—์„œ๋„ ์ •๊ทœ์‹์œผ๋กœ URL ์ถ”์ถœ
  3. ์ €์žฅ ํ›„ ์ž๋™ ๋‹ซ๊ธฐ

    • ์ €์žฅ ์™„๋ฃŒ โ†’ ์งง๊ฒŒ ํ”ผ๋“œ๋ฐฑ โ†’ close() ํ˜ธ์ถœ๋กœ Share Extension ์ข…๋ฃŒ

5. ์ธ์ฆ ํ† ํฐ ๊ณต์œ : SecureStore + Keychain Access Group

์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” โ€œExtension์—์„œ๋„ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋กœ API๋ฅผ ํ˜ธ์ถœโ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
์ด๊ฑธ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด iOS Keychain ๊ณต์œ ๋ฅผ ์”๋‹ˆ๋‹ค.

5-1. ์˜ˆ์‹œ ์ฝ”๋“œ

// 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-groups entitlement์™€ accessGroup ๊ฐ’์ด ์„œ๋กœ ์ผ๊ด€๋˜๊ฒŒ ์„ค์ •๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ํŒ€ ID ์ ‘๋‘(ABCDE12345.)๋ฅผ ์–ด๋–ป๊ฒŒ ๋ถ™์ผ์ง€๋Š” ํ”„๋กœ์ ํŠธ๋งˆ๋‹ค ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์–ด์„œ, ์—ฌ๊ธฐ์„  ์˜๋„๋งŒ ๋ณด์ด๋Š” ์˜ˆ์‹œ๋กœ ์ ์—ˆ์Šต๋‹ˆ๋‹ค.

6. Android: Intent Filter + expo-share-intent

Android๋Š” Share Extension์ด ๋”ฐ๋กœ ์—†๊ณ , Intent Filter๋ฅผ ๋งค๋‹ˆํŽ˜์ŠคํŠธ(app.json)๋กœ ์„ค์ •ํ•œ ๋’ค, expo-share-intent ํ›…์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.

6-1. Android intentFilters (app.json ์˜ˆ์‹œ)

"android": {
  "intentFilters": [
    {
      "action": "android.intent.action.SEND",
      "category": ["android.intent.category.DEFAULT"],
      "data": {
        "mimeType": "text/plain"
      }
    }
  ]
}

6-2. ๊ณต์œ  ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ (Expo Router ์˜ˆ์‹œ)

// 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 ํ™˜๊ฒฝ์—์„œ ํ…Œ์ŠคํŠธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.


7. ๋„ค์ดํ‹ฐ๋ธŒ ์ฝ”๋“œ ๊ฐœ๋…๋งŒ ์‚ด์ง ๋ณด๊ธฐ (Swift ์˜ˆ์‹œ)

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)
  }
}

์—ญํ• ์€ ๋‹จ์ˆœํ•ฉ๋‹ˆ๋‹ค.

  1. extensionContext์—์„œ ๊ณต์œ ๋œ ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ
  2. React Native shareExtension ๋ชจ๋“ˆ์„ ๋„์šฐ๋ฉฐ initialProperties๋กœ ์ „๋‹ฌ
  3. JS์—์„œ close() ํ˜ธ์ถœ ์‹œ ๋„ค์ดํ‹ฐ๋ธŒ์—์„œ completeRequest๋กœ Extension ์ข…๋ฃŒ

8. EAS Build์—์„œ App 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๊ฐ€

    • Share Extension์šฉ Provisioning Profile๊นŒ์ง€ ๊ฐ™์ด ์ƒ์„ฑํ•ด ์ค˜์„œ
    • โ€œiOS ๋นŒ๋“œ๋งŒ ๋Œ๋ฆฌ๋ฉด ๋˜๊ฒŒโ€ ๋„์™€์ค๋‹ˆ๋‹ค.

9. ๋‚ด์šฉ ๊ฒ€์ฆ & ์ฃผ์˜ํ•  ์  ์š”์•ฝ

์งˆ๋ฌธ ์ฃผ์…จ๋˜ โ€œํ‹€๋ฆฐ ๋‚ด์šฉ์ด ์žˆ๋Š”์ง€โ€ ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌํ•˜๋ฉด:

  1. expo-share-extension ์‚ฌ์šฉ ๋ฐฉ์‹

    • activationRules, appGroupIdentifier, index.share.js์—์„œ "shareExtension" ๋“ฑ๋ก ๋“ฑ์€
      ๊ณต์‹ README์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.
  2. expo-share-intent ์‚ฌ์šฉ ๋ฐฉ์‹

    • useShareIntent ํ›… ์‚ฌ์šฉ, disableIOS, androidIntentFilters ์„ค์ • ๋ชจ๋‘
      ๊ณต์‹ README์™€ ํŒจํ„ด์ด ๋™์ผํ•ฉ๋‹ˆ๋‹ค.
  3. โ€œApp Group์„ ํ†ตํ•œ ๋ฐ์ดํ„ฐ ๊ณต์œ โ€๋ผ๋Š” ์„ค๋ช…

    • iOS์—์„œ ๋ฉ”์ธ ์•ฑ/Share Extension ์‚ฌ์ด ๋ฐ์ดํ„ฐ ๊ณต์œ ๋Š”

      • App Group (ํŒŒ์ผ/์ปจํ…Œ์ด๋„ˆ ๊ณต์œ ) +
      • Keychain Access Group (SecureStore ๋“ฑ ํ‚ค์ฒด์ธ ๊ณต์œ )
        ์กฐํ•ฉ์œผ๋กœ ๊ตฌํ˜„ํ•˜๋Š” ํŒจํ„ด์ด ๋งž์Šต๋‹ˆ๋‹ค.
  4. EAS Build + appExtensions ์„ค์ •

    • extra.eas.build.experimental.ios.appExtensions๋ฅผ ํ†ตํ•ด
      EAS๊ฐ€ ๋ฏธ๋ฆฌ extension ์ •๋ณด๋ฅผ ์•Œ๊ณ  ์„œ๋ช…/ํ”„๋กœ๋น„์ €๋‹์„ ์ฒ˜๋ฆฌํ•ด ์ค€๋‹ค๋Š” ์„ค๋ช…๋„
      ์ตœ์‹  Expo ๋ฌธ์„œ์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.
  5. ํ† ํฐ ๊ณต์œ  ์ฝ”๋“œ์— ๋Œ€ํ•œ ํ•œ ๊ฐ€์ง€ ์ฃผ์˜

    • ์˜ˆ์‹œ์—์„œ๋Š” ์ดํ•ด๋ฅผ ์œ„ํ•ด accessGroup: 'ABCDE12345.*'์ฒ˜๋Ÿผ ๋‹จ์ˆœํ™”ํ–ˆ์ง€๋งŒ,
    • ์‹ค์ œ ์•ฑ์—์„œ๋Š” Apple Team ID + ๊ทธ๋ฃน๋ช…์ด ํฌํ•จ๋œ ์ •ํ™•ํ•œ Keychain ๊ทธ๋ฃน ๊ฐ’์„ ์จ์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ์ด ๋ถ€๋ถ„์€ ๊ฐ์ž Apple ๊ณ„์ •/๊ธฐ์กด ์•ฑ ๊ตฌ์กฐ์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์„œ, ๊ธ€์—์„œ๋Š” โ€œํŒจํ„ด๋งŒโ€ ๋ณด์—ฌ์ฃผ๋Š” ์ˆ˜์ค€์œผ๋กœ ๋‘์—ˆ์Šต๋‹ˆ๋‹ค.

10. ๐Ÿงช ๋งํฌ ๋“œ๋ผํผ, ์ •์‹ ์ถœ์‹œ!

๋งํฌ ๋“œ๋ผํผ๋Š” ๋‹จ์ˆœํ•œ ์ €์žฅ ํˆด์ด ์•„๋‹™๋‹ˆ๋‹ค.
์ •๋ฆฌํ•˜๊ณ , ๋‹ค์‹œ ๊บผ๋‚ด๋ณด๊ฒŒ ๋งŒ๋“œ๋Š” ๋งํฌ ๊ด€๋ฆฌ ๋„๊ตฌ๋ฅผ ์ง€ํ–ฅํ•˜๊ณ  ์žˆ์–ด์š”.

๐Ÿ”— ๋น ๋ฅด๊ณ  ๊ฐ„ํŽธํ•œ ๋งํฌ ์ €์žฅ
iOS/Android ์•ฑ, ์›น, ํฌ๋กฌ ์ต์Šคํ…์…˜ ์–ด๋””์„œ๋“  ๋ฐ”๋กœ ์ €์žฅ
๐Ÿง  ํด๋”๋ณ„๋กœ ๊น”๋”ํ•˜๊ฒŒ ์ •๋ฆฌ
์ฝ์„ ๊ฑฐ๋ฆฌ, ๋ ˆํผ๋Ÿฐ์Šค, ์‡ผํ•‘ ํ›„๋ณด๊นŒ์ง€ ์ฃผ์ œ๋ณ„ ์ •๋ฆฌ
๐ŸŒ ํด๋”๋ฅผ ์นœ๊ตฌ์—๊ฒŒ ๊ณต์œ 
๊ฐ™์ด ๋ณด๋Š” ์ž๋ฃŒ๋Š” ํด๋” ๋‹จ์œ„๋กœ ๋งํฌ ํ•œ ๋ฒˆ์— ๊ณต์œ 
โšก ํฌ๋กฌ ์ต์Šคํ…์…˜ ์›ํด๋ฆญ ์ €์žฅ
์ง€๊ธˆ ๋ณด๊ณ  ์žˆ๋Š” ํŽ˜์ด์ง€๋ฅผ ๋ฒ„ํŠผ ํ•œ ๋ฒˆ์œผ๋กœ ์ €์žฅ

๐Ÿ‘‰ ๋งํฌ ๋“œ๋ผํผ ์•ฑ ๋‹ค์šด๋กœ๋“œ (iOS / Android)
๐Ÿ‘‰ ๋งํฌ ๋“œ๋ผํผ ์›น์—์„œ ์‚ฌ์šฉํ•˜๋Ÿฌ ๊ฐ€๊ธฐ
๐Ÿ‘‰ ํฌ๋กฌ ์›น์Šคํ† ์–ด์—์„œ ์ต์Šคํ…์…˜ ์„ค์น˜ํ•˜๊ธฐ

profile
โ€œ๊ธฐ๋กํ•˜๋Š” ์Šต๊ด€์„ ๋„๊ตฌ๋กœ ๋งŒ๋“ค๋‹ค โ€” ๋‘ ๊ฐœ๋ฐœ์ž์˜ ๋งํฌ ๋“œ๋ผํผ ๊ตฌ์ถ•๊ธฐโ€

0๊ฐœ์˜ ๋Œ“๊ธ€