앨범, 카메라 권한 관련 버그 및 관련 정책 확인 요청드립니다

안녕하세요, 임플로이랩스입니다.
커뮤니티 생겨서 좋네요..후련합니다. 채널톡 글자수 제한 빔;;

1.앨범 권한 관련 버그

fetchAlbumPhotos로 권한 획득시 maxCount값을 1로 설정하면 에러가 발생합니다.

  • Max items must be higher than 1

사진을 다수 첨부하는 경우도 있지만 1개만 첨부해야 하는 경우도 있는데 1개 첨부가 불가합니다.
일단 출시가 먼저라 최소 숫자인 2로 설정하고, 첫번째 사진을 받아서 처리하고 있습니다.
다행이 유저는 서비스 UX상 1장만 업로드 할거라는 기대치가 있기 때문에 당장은 문제가 없습니다.

다만 정책으로 1이 설정 불가능한 부분은 버그…아닐까 싶어서요.
혹은 제가 놓친 부분이 있다면 노티 부탁드립니다.

2.앨범, 카메라 권한 거부시 CTA 버튼명 및 복원 방안

기존 ios/aos 앱은 사용자가 권한을 거부하거나 창을 그냥 닫더라도 3번 정도 요청이 가능합니다.
테스트하면서 느낀건데 한번 실패하면 그 다음부터는 다시 시도를 하지 않더라고요.
더불어 현재 UI 상 “허용”, “한번만 허용”, "안하기"로 되어있어서 유저 사용성을 해친다고 봅니다.

  1. 안하기 → 안하기가 아니라 실제 거부임 → 유저는 "거부"라 생각지 못하고 "안하기"를 누르는 경우가 있습니다.
    거기다 안하기 버튼이 그냥 작은 텍스트로 되어있는데..아..UI 함 보시면 이걸 거부하기라고 생각하기 어렵습니다.

  2. 저 3개중에 반드시 한개를 눌러야 눌러야 벗어날수 있습니다. “레알 안하기”는 딤드창을 눌러서 닫는건데..딤드 닫기도 막아두었더라고요. 실제로 앱 사용자들은 아무런 의사표시를 하지 않는것으로 백버튼이나 딤드를 선택하는 경우가 많습니다. 그런데 미니앱은 한번 띄운이상 무조건 강요하도록 되어있어서..거기에 거부하기가 안하기로 표시되어 있어서..

  3. 안하기(거부)한 경우 유저는 다시 복구할 방법이 없습니다. ..개발문서에서는 못찾았어요.
    복구하려면 앱 캐시를 날려야 합니다. IOS는 앱삭제후 재설치를 해야만 가능하고요

위 내용에 대해 확인 부탁드립니다.

제가 가장 우려가 되는 유저 시나리오는
“앱 접속 → 사진첩 클릭 → (실수든 자의든) 안하기 누름 → 복구불가 상태 빠짐 → 미니앱(or토스) 불만 or 고객센터 문의후 답변기다리다가 앱캐시 직접 날려서 해결하고 늦게 온 답변에 불만” 입니다.

제 생각에는 이미 토스 유저는 OS레벨에서 토스의 네티이브 권한 획득 과정을 거치고 이후 미니앱의 경우 소프트 권한을 획득하는건데, 미니앱의 거부한것을 찾아서 다시 번복하는 기능을 만드는것은 좋은 선택지가 아닌것 같고요
아래처럼 하면 좋을거 같은데..

  1. 안하기 → 닫기 로 기능 변경 (거부가 아님)
  2. 딤드창 닫기 허용 → (아무런 의사표시 없는)
  3. 결과적으로 거부하기 기능이 없음

거부하기 기능은 “악의적인 권한 요청 프로세스로 사용자를 불편하게 만드는 경우”를 예방하는것이 가장 큰 목적인데…그게 미니앱에서도 과연 그럴까 싶어요. 제 생각엔 얻는거보다 잃는게 더 많을 것 같습니다..

어쨌든 저희 역시 사용성을 우선하고 있어서…별거 아닐거라 생각되셔도 한번 검토 부탁드립니다.

고맙습니다.

안녕하세요

먼저 커뮤니티에 대해 반갑게 맞이해주셔서 감사해요 :laughing:

1. 앨범 권한 버그 → 확인해보겠습니다.

2. 권한

말씀 주신 부분이 맞습니다. 가장 우선순위 높에 보고 있던 작업이라 금일 수정 배포될 예정이에요.

사용자가 권한을 거부하거나 창을 닫을 경우, 다시 띄울 수 있도록 수정될 예정입니다.

추후에는 사용자가 공통 내비게이션 bar의 옵션 설정으로 미니앱 별로 권한을 제어할 수 있는 기능을 넣을 예정입니다.

금일 수정 완료되면 공유드리겠습니다.

기타 궁금하신 내용이 있으시면 언제든지 문의해주세요.

감사합니다.

안녕하세요 :slight_smile:

      const response = await fetchAlbumPhotos({
        base64,
        maxCount: 1,
        maxWidth: 360,
      });

maxCount 를 1로 넘겨도 에러가 발생하지 않는데, 재현 과정을 알려주실 수 있을까요 ?
Webview 로 개발중이실까요 ?

@seonjeong

확인 감사드립니다!

@Dylan

웹뷰로 postmessage 처리하기는 하는데 테스트는 개발가이드 소스로 해도 동일합니다.

아래 코드로 실행하면

import { fetchAlbumPhotos, ImageResponse } from '@apps-in-toss/framework';
import { Button, Asset } from '@toss-design-system/react-native';
import { useState } from 'react';
import { View } from 'react-native';

const base64 = true;

// 앨범 사진 목록을 가져와 화면에 표시하는 컴포넌트
export default function AlbumPhotoList() {
  const [albumPhotos, setAlbumPhotos] = useState<ImageResponse[]>([]);

  async function handlePress() {
    try {
      const response = await fetchAlbumPhotos({
        base64,
        maxCount: 1,
        maxWidth: 360,
      });
      setAlbumPhotos((prev) => [...prev, ...response]);
    } catch (error) {
      console.error('앨범을 가져오는 데 실패했어요:', error);
    }
  }

  return (
    <View>
      {albumPhotos.map((image) => {
        // base64 형식으로 반환된 이미지를 표시하려면 데이터 URL 스키마 Prefix를 붙여야해요.
        const imageUri = base64 ? 'data:image/jpeg;base64,' + image.dataUri : image.dataUri;

        return <Asset.Image key={image.id} source={{ uri: imageUri }} />;
      })}
      <Button onPress={handlePress}>앨범 가져오기</Button>
    </View>
  );
}

에러 떨어지고요.

 ERROR  앨범을 가져오는 데 실패했어요: [Error: Max items must be higher than 1]

maxcount 2로 바꾸면 에러 안나고 잘 됩니다.

혹시 사용하시는 framework 버전을 알 수 있을까요 ?
maxCount 1로 했을때 오류가 발생하지 않는데요.. :cry:
framework를 최신버전으로 업데이트 후 테스트 부탁드려도 될까요 ?

테스트한 코드 첨부드려요

import { createRoute } from '@granite-js/react-native';
import React, { useState } from 'react';
import { StyleSheet, View, Text, Image } from 'react-native';
import { Button} from '@toss-design-system/react-native';
import { fetchAlbumPhotos, ImageResponse} from '@apps-in-toss/framework';

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

function AlbumPhotoList() {
  const [albumPhotos, setAlbumPhotos] = useState<ImageResponse[]>([]);

  async function handlePress() {
    try {
      console.log('앨범 사진 가져오기 시작...');
      const response = await fetchAlbumPhotos({
        base64,
        maxCount: 1,
        maxWidth: 360,
      });
      console.log('가져온 사진 개수:', response.length);
      console.log('가져온 사진 데이터:', response);
      setAlbumPhotos((prev) => [...prev, ...response]);
    } catch (error) {
      console.error('앨범을 가져오는 데 실패했어요:', error);
    }
  }

  return (
    <View style={styles.albumContainer}>
      <Text style={styles.albumTitle}>앨범 사진</Text>
      {albumPhotos.length === 0 ? (
        <Text style={styles.noPhotoText}>사진이 없습니다. 버튼을 눌러 앨범에서 사진을 가져오세요.</Text>
      ) : (
        albumPhotos.map((image) => {
          // base64 형식으로 반환된 이미지를 표시하려면 데이터 URL 스키마 Prefix를 붙여야해요.
          const imageUri = base64 ? 'data:image/jpeg;base64,' + image.dataUri : image.dataUri;
          
          console.log('이미지 ID:', image.id);
          console.log('이미지 dataUri 길이:', image.dataUri?.length);
          console.log('최종 imageUri:', imageUri.substring(0, 100) + '...');

          return (
            <Image 
              key={image.id} 
              source={{ uri: imageUri }} 
              style={styles.albumImage}
              resizeMode="cover"
            />
          );
        })
      )}
      <View style={styles.albumButton}>
        <Button onPress={handlePress}>
          앨범 가져오기
        </Button>
      </View>
    </View>
  );
}

function Page() {
  return (
    <Container>
      <AlbumPhotoList />
    </Container>
  );
}

function Container({ children }: { children: React.ReactNode }) {
  return <View style={styles.container}>{children}</View>;
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
  },
  brandText: {
    color: '#0064FF',
    fontWeight: 'bold',
  },
  text: {
    fontSize: 24,
    color: '#202632',
    textAlign: 'center',
    marginBottom: 10,
  },
  title: {
    fontSize: 32,
    fontWeight: 'bold',
    color: '#1A202C',
    textAlign: 'center',
    marginBottom: 16,
  },
  subtitle: {
    fontSize: 18,
    color: '#4A5568',
    textAlign: 'center',
    marginBottom: 24,
  },
  description: {
    fontSize: 16,
    color: '#718096',
    textAlign: 'center',
    marginBottom: 32,
    lineHeight: 24,
  },
  button: {
    backgroundColor: '#0064FF',
    paddingVertical: 12,
    paddingHorizontal: 32,
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    elevation: 5,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: 'bold',
    textAlign: 'center',
  },
  codeContainer: {
    padding: 8,
    backgroundColor: '#333',
    borderRadius: 4,
    width: '100%',
  },
  code: {
    color: 'white',
    fontFamily: 'monospace',
    letterSpacing: 0.5,
    fontSize: 14,
  },
  albumContainer: {
    marginTop: 20,
    alignItems: 'center',
    width: '100%',
  },
  albumTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    color: '#1A202C',
    marginBottom: 16,
  },
  albumImage: {
    width: 200,
    height: 200,
    borderRadius: 12,
    marginBottom: 16,
    borderWidth: 2,
    borderColor: '#E2E8F0',
  },
  albumButton: {
    marginTop: 8,
  },
  noPhotoText: {
    fontSize: 14,
    color: '#718096',
    textAlign: 'center',
    marginBottom: 16,
    fontStyle: 'italic',
  },
});

빠른 확인 감사드립니다.
0.0.40 버전이고 캐시 클린, 재설치까지 해서 다시 테스트 해봤어요

type package.json | findstr “@apps-in-toss/framework”
@apps-in-toss/framework”: “^0.0.40”,

npm view @apps-in-toss/framework version
0.0.40

npm cache clean --force
npm warn using --force Recommended protections disabled.

npm uninstall @apps-in-toss/framework
removed 23 packages, and audited 1108 packages in 13s

npm install
up to date, audited 1131 packages in 4s

npm list @apps-in-toss/framework
…-- @apps-in-toss/framework@0.0.40

주신 코드에서 라우트만 제거하고 console.error(‘에러 스택:’, error.stack); 하나 추가해서 테스트했고

import React, { useState } from 'react';
import { StyleSheet, View, Text, Image } from 'react-native';
import { Button} from '@toss-design-system/react-native';
import { fetchAlbumPhotos, ImageResponse} from '@apps-in-toss/framework';

const base64 = true;

function AlbumPhotoList() {
  const [albumPhotos, setAlbumPhotos] = useState<ImageResponse[]>([]);

  async function handlePress() {
    try {
      console.log('앨범 사진 가져오기 시작...');
      const response = await fetchAlbumPhotos({
        base64,
        maxCount: 1,
        maxWidth: 360,
      });
      console.log('가져온 사진 개수:', response.length);
      console.log('가져온 사진 데이터:', response);
      setAlbumPhotos((prev) => [...prev, ...response]);
    } catch (error) {
      console.error('앨범을 가져오는 데 실패했어요:', error);
      console.error('에러 스택:', error.stack);
    }
  }

  return (
    <View style={styles.albumContainer}>
      <Text style={styles.albumTitle}>앨범 사진</Text>
      {albumPhotos.length === 0 ? (
        <Text style={styles.noPhotoText}>사진이 없습니다. 버튼을 눌러 앨범에서 사진을 가져오세요.</Text>
      ) : (
        albumPhotos.map((image) => {
          // base64 형식으로 반환된 이미지를 표시하려면 데이터 URL 스키마 Prefix를 붙여야해요.
          const imageUri = base64 ? 'data:image/jpeg;base64,' + image.dataUri : image.dataUri;

          console.log('이미지 ID:', image.id);
          console.log('이미지 dataUri 길이:', image.dataUri?.length);
          console.log('최종 imageUri:', imageUri.substring(0, 100) + '...');

          return (
            <Image
              key={image.id}
              source={{ uri: imageUri }}
              style={styles.albumImage}
              resizeMode="cover"
            />
          );
        })
      )}
      <View style={styles.albumButton}>
        <Button onPress={handlePress}>
          앨범 가져오기
        </Button>
      </View>
    </View>
  );
}

export default function Page() {
  return (
    <Container>
      <AlbumPhotoList />
    </Container>
  );
}

function Container({ children }: { children: React.ReactNode }) {
  return <View style={styles.container}>{children}</View>;
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
  },
  albumContainer: {
    marginTop: 20,
    alignItems: 'center',
    width: '100%',
  },
  albumTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    color: '#1A202C',
    marginBottom: 16,
  },
  albumImage: {
    width: 200,
    height: 200,
    borderRadius: 12,
    marginBottom: 16,
    borderWidth: 2,
    borderColor: '#E2E8F0',
  },
  albumButton: {
    marginTop: 8,
  },
  noPhotoText: {
    fontSize: 14,
    color: '#718096',
    textAlign: 'center',
    marginBottom: 16,
    fontStyle: 'italic',
  },
});

아래처럼 여전히 에러가 발생합니다.

 LOG  앨범 사진 가져오기 시작...
 ERROR  앨범을 가져오는 데 실패했어요: [Error: Max items must be higher than 1]
 ERROR  에러 스택: Error: Max items must be higher than 1
    at promiseMethodWrapper (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:5970:45)
    at anonymous (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:227356:53)
    at call (native)
    at anonymous (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:226471:1500)
    at anonymous (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:226472:264)
    at asyncGeneratorStep (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:226475:72)
    at _next (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:226476:183)
    at tryCallOne (/root/react-native/packages/react-native/ReactAndroid/hermes-engine/.cxx/MinSizeRel/a32195v3/x86_64/lib/InternalBytecode/InternalBytecode.js:53:16)
    at anonymous (/root/react-native/packages/react-native/ReactAndroid/hermes-engine/.cxx/MinSizeRel/a32195v3/x86_64/lib/InternalBytecode/InternalBytecode.js:139:27)
    at apply (native)
    at anonymous (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:31007:26)
    at _callTimer (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:30926:17)
    at _callReactNativeMicrotasksPass (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:30956:17)
    at callReactNativeMicrotasks (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:31119:44)
    at __callReactNativeMicrotasks (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:4956:46)
    at anonymous (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:4767:45)
    at __guard (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:4939:15)
    at flushedQueue (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:4766:21)
    at invokeCallbackAndReturnFlushedQueue (http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false&app=viva.republica.toss.test&modulesOnly=false&runModule=true:4760:33)

테스트는 안드로이드 시뮬레이터로 했어요

샌드박스는

image

개발은 하긴 하지만..찐 개발자는 아니라서 제가 놓치는게 있을지도 모르겠습니다.

더 확인이 필요한 부분 있으면 말씀해주시고요~

헉 안드로이드 시뮬레이터에서 해당 이슈 재현되었습니다.
빠르게 조치하고 말씀드릴게요 :man_bowing:

1개의 좋아요

아직 이 문제는 해결 중에 있나요?

ERROR 앨범을 가져오는 데 실패했어요: [Error: Max items must be higher than 1]
아직 에러가 뜹니다.

202509091913 안드로이드입니다.

├── @apps-in-toss/framework@1.1.2
├── @babel/core@7.23.9
├── @babel/plugin-proposal-class-properties@7.18.6
├── @babel/plugin-proposal-nullish-coalescing-operator@7.18.6
├── @babel/plugin-proposal-numeric-separator@7.18.6
├── @babel/plugin-proposal-optional-chaining@7.21.0
├── @babel/plugin-proposal-private-methods@7.18.6
├── @babel/plugin-proposal-private-property-in-object@7.21.11
├── @babel/plugin-transform-flow-strip-types@7.27.1
├── @babel/preset-env@7.28.3
├── @babel/preset-react@7.27.1
├── @babel/preset-typescript@7.27.1
├── @babel/runtime@7.18.9
├── @granite-js/native@0.1.21
├── @granite-js/plugin-router@0.1.21
├── @granite-js/react-native@0.1.21
├── @testing-library/react-native@12.9.0
├── @toss-design-system/react-native@1.2.1
├── @types/babel__core@7.20.5
├── @types/jest@29.5.14
├── @types/node@22.18.6
├── @types/react@18.3.3
├── jest@29.7.0
├── react-native@0.72.6
├── react-test-renderer@18.2.0
├── react@18.2.0
└── typescript@5.9.2

@Line1 님 안녕하세요

토스앱 5.230.0 버전으로 업데이트 후 확인 부탁드립니다.