iOS 심사에서만 권한 플로우가 깨지는 사례 해결해보신분 ㅜㅜ

  • 개발 빌드에서는 영수증 업로드 → 커스텀 바텀싯(카메라/앨범 선택) → 권한
    다이얼로그 → 촬영/선택 흐름이 정상.
  • 빌드 심사에서는 “카메라/앨범 선택 시 바텀싯이 잠깐 보였다가
  • 바로 사라져서 진행이 어렵다”는 피드백을 반복 수신.

혹시 토스 미니앱에서 권한 요청 전에 커스텀 안내 바텀싯을 한 번 더 띄우는
사례나, openPermissionDialog 호출 타이밍을 조정해서 심사 통과한 경험이
있나요 ㅠ 계속 반려되네여

@choi_j 님 안녕하세요

미니앱 이름이 어떻게 되실까요?

1개의 좋아요

안녕하세요 :slight_smile:
커스텀 바텀싯이 닫히면서 권한 다이얼로그가 같이 닫히는게 아닐지요 ..?

1개의 좋아요

바로 더치 baro-dutch 입니다 ㅜㅜ

음 … 로컬에서는 몇번 테스트해봐도 서로 영향을 주는게 없는데 심사만 가면 올라왔다 바로 내려간다 그래서 좀 혼란스럽네요 ㅠ

혹시 관련 코드를 메세지로 공유주실 수 있을까요 ?! 저도 테스트 해보겠습니다.

혹시 어떤 코드 말씀하시는 걸까요??

아 제 로컬에서 테스트할 수 있도록 작성하신 코드 전문을 주실 수 있을지요 :slight_smile: ..

넵 ! 잠시만요 메세지로 보내드릴게요 !

코드를 확인해보니, 권한 확인이 되기 전에 모달을 setModalVisible(false) 로 종료시키고 계신 것 같습니다.
테스트한 코드를 공유드려요.
intoss-private://my-granite-app?_deploymentId=019aa0f6-99de-796a-91c8-2a7470640c4e

import { createRoute } from '@granite-js/react-native';
import React, { useState } from 'react';
import {
  StyleSheet,
  View,
  Text,
  Modal,
  TouchableWithoutFeedback,
} from 'react-native';
import { Button } from '@toss/tds-react-native';
import {
  fetchAlbumPhotos,
  FetchAlbumPhotosPermissionError,
  openCamera,
  OpenCameraPermissionError,
} from '@apps-in-toss/framework';

export const Route = createRoute('/', {
  component: Page,
});

function Page() {
  const [isModalVisible, setModalVisible] = useState(false);

  const isPermissionGranted = (status: string | null | undefined) => status === 'allowed' || status === 'limited';

  const isPermissionRequiredMessage = (error: unknown, keyword: 'camera' | 'album') => {
    if (!(error instanceof Error)) {
      return false;
    }

    const message = error.message.toLowerCase();
    if (keyword === 'camera') {
      return message.includes('camera permission is required') || message.includes('camera permission required');
    }
    return (
      message.includes('album permission is required') ||
      message.includes('album permission required') ||
      message.includes('photo permission is required') ||
      message.includes('photos permission is required') ||
      message.includes('photo library permission is required')
    );
  };

  const ensurePermission = async (type: 'camera' | 'album') => {
    const permissionAPI = type === 'camera' ? openCamera : fetchAlbumPhotos;

    if (
      typeof permissionAPI.getPermission !== 'function' ||
      typeof permissionAPI.openPermissionDialog !== 'function'
    ) {
      return true;
    }

    try {
      const currentPermission = await permissionAPI.getPermission();
      if (isPermissionGranted(currentPermission)) {
        console.log(`${type === 'camera' ? '카메라' : '앨범'} 권한이 이미 허용되어 있습니다.`);
        return true;
      }

      const requestedPermission = await permissionAPI.openPermissionDialog();
      const granted = isPermissionGranted(requestedPermission);
      console.log(`${type === 'camera' ? '카메라' : '앨범'} 권한 요청 결과:`, granted ? '허용됨' : '거부됨');
      return granted;
    } catch (error) {
      console.error(
        `${type === 'camera' ? '카메라' : '앨범'} 권한 상태를 확인하는 중 문제가 발생했습니다:`,
        error,
      );
      return false;
    }
  };

  const handleCamera = async () => {
    try {
      const hasPermission = await ensurePermission('camera');
      // 권한 확인이 완료된 후에 모달 닫기
      setModalVisible(false);
      if (hasPermission) {
        console.log('카메라 권한 확인 완료');
      }
    } catch (error) {
      // 에러가 발생해도 모달 닫기
      setModalVisible(false);
      if (error instanceof OpenCameraPermissionError || isPermissionRequiredMessage(error, 'camera')) {
        console.log('카메라 권한이 필요합니다.');
      } else {
        console.error('카메라 실행에 실패했어요:', error);
      }
    }
  };

  const handleLibrary = async () => {
    try {
      const hasPermission = await ensurePermission('album');
      // 권한 확인이 완료된 후에 모달 닫기
      setModalVisible(false);
      if (hasPermission) {
        console.log('앨범 권한 확인 완료');
      }
    } catch (error) {
      // 에러가 발생해도 모달 닫기
      setModalVisible(false);
      if (error instanceof FetchAlbumPhotosPermissionError || isPermissionRequiredMessage(error, 'album')) {
        console.log('앨범 권한이 필요합니다.');
      } else {
        console.error('앨범을 가져오는 데 실패했어요:', error);
      }
    }
  };

  return (
    <View style={styles.screen}>
      <View style={styles.container}>
        <Text style={styles.title}>권한 테스트</Text>
        <Button onPress={() => setModalVisible(true)} size="large" style="fill" display="block">
          영수증 업로드
        </Button>
      </View>

      <Modal
        animationType="slide"
        transparent
        visible={isModalVisible}
        onRequestClose={() => {
          setModalVisible(false);
        }}
      >
        <TouchableWithoutFeedback
          onPress={() => {
            setModalVisible(false);
          }}
        >
          <View style={styles.bottomSheetOverlay}>
            <TouchableWithoutFeedback onPress={() => {}}>
              <View style={styles.bottomSheetContainer}>
                <Text style={styles.bottomSheetTitle}>영수증 업로드</Text>
                <View style={{ gap: 8 }}>
                  <Button
                    size="large"
                    style="weak"
                    display="block"
                    onPress={() => {
                      void handleCamera();
                    }}
                  >
                    카메라로 촬영
                  </Button>
                  <Button
                    size="large"
                    style="weak"
                    display="block"
                    onPress={() => {
                      void handleLibrary();
                    }}
                  >
                    앨범에서 선택
                  </Button>
                  <Button size="large" style="weak" display="block" onPress={() => setModalVisible(false)}>
                    취소
                  </Button>
                </View>
              </View>
            </TouchableWithoutFeedback>
          </View>
        </TouchableWithoutFeedback>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    backgroundColor: 'white',
  },
  container: {
    flex: 1,
    paddingHorizontal: 24,
    paddingVertical: 16,
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#1A202C',
    marginBottom: 24,
  },
  bottomSheetOverlay: {
    flex: 1,
    justifyContent: 'flex-end',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  bottomSheetContainer: {
    backgroundColor: 'white',
    borderTopLeftRadius: 16,
    borderTopRightRadius: 16,
    paddingHorizontal: 24,
    paddingTop: 24,
    paddingBottom: 32,
  },
  bottomSheetTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    color: '#1A202C',
    textAlign: 'center',
    marginBottom: 24,
  },
});

export default Page;

테스트 정말 감사합니다 ! 근데 제가 의도한건 그 업수증 업로드 버튼 선택하면 권한 동의 하고 바로 쭉 앨범선택 까지 이어지는 flow 인데 그 영수증 모달이 내려가는게 문제가 된걸까요? ㅠㅠㅠ

심사측에서는
**
영수증 업로드 > 앨범에서 선택 / 카메라 촬영 터치 > 권한 동의 바텀싯이 정상적으로 노출되지 않아 플로우를 진행할 수 없습니다.**

권한 동의 바텀싯에서 문제가 생기는 것 같아서요 !

그 영수증 업로드 모달은 닫히도록 설계를 한건데 그게 잘못된걸까요? ㅠ 어렵네요

제가 확인했을때는 모달이 닫히면서 권한동의 창이 같이 닫혔었습니다!
setModalVisible(false) 이 함수 호출 시점을 권한 확인 완료 후로 바꿔보시면 어떨까요 ?

1개의 좋아요

말씀 감사합니다 ! 무사히 통과 되었습니다 !!!