Flutter - 애플 앱스토어 리젝 사유 Guideline 2.1 Information Needed AppTrackingTransparency

Posted by , October 27, 2023
FlutterAppStoreTroubleshooting
Series ofFlutter

thumbnail

앱스토어 총 세 번째 리젝...

지금 글을 쓰는 27일 오후 시간 기준으로 막 심사에 들어갔다.

무엇이 날 이렇게 괴롭혔는가?
바로 AppTrackingTransparency framework 라는 앱 추적 투명성 관련 문제다.

나는 플러터로 개발을 했고, 해당 이슈를 처음엔 몰랐었다.
처음 리젝을 받고 해당 문제를 알게 되었다.
사실 웹 개발(Nest.Js + Next.Js)만 하다 와서 모바일 쪽 감을 잡지 못했었다.

무튼 처음 앱 개발을 해본 분이라면 이런 전문을 많이 받았을 것이다.

Guideline 2.1 - Information Needed

We're looking forward to completing our review, but we need more information to continue. Your app uses the AppTrackingTransparency framework, but we are unable to locate the App Tracking Transparency permission request when reviewed on iOS 17.0.3.

Next Steps

Please explain where we can find the App Tracking Transparency permission request in your app. The request should appear before any data is collected that could be used to track the user.

If you've implemented App Tracking Transparency but the permission request is not appearing on devices running the latest OS, please review the available documentation and confirm App Tracking Transparency has been correctly implemented.

If your app does not track users, update your app privacy information in App Store Connect to not declare tracking. You must have the Account Holder or Admin role to update app privacy information.

Resources

- Tracking is linking data collected from your app with third-party data for advertising purposes, or sharing the collected data with a data broker. Learn more about tracking.
- See Frequently Asked Questions about the requirements for apps that track users.
- Review developer documentation for App Tracking Transparency.

리뷰어마다 친절도가 다른데 처음 리뷰해준 사람은 위와 같이 상세하게 적어줬지만, 두 번째 리뷰어는 그냥 양식만 대충 해서 줬다.
물론 해결되지 않은 이슈 및 새로운 이슈 때문에 간략하게 적은 것일 수 도 있다.

무튼 이 문제는 현재 마지막 리뷰를 기다리는 중인데 어떻게 처리해야 하는지에 대해 포스팅을 해본다.

app_tracking_transparency

사실 Swift랑 아이폰 개발 관련 지식이 조금이라도 있다면 AppDelegate.swift쪽에서 처리할 수 있겠지만,
난 이제 iOS 개발지식이 사라진 상태다.
그래서 그냥 간편하게 Flutter 라이브러리인 app_tracking_transparency를 이용하기로 했다.

img03

대충 메인 페이지만 봐도 내가 원하는 이미지가 나온다.
맨날 앱 설치하면 저게 처음 떴는데 이런 이유였구나 싶다.

보통 광고를 달고, 수집 개인정보 항목이 있다면 이 가이드라인이 적용되는 것 같다.
이 라이브러리를 적용하는 법은 매우 간단하다.

pub.dev에 나온 대로 하면 되는데 귀찮은 분을 위해 여기다 간단히 요약을 하면 아래와 같다.

1. iOS 사전 작업 (info.plist)

다음의 경로에 있는 info.plist를 연다.

iOS/Runner/info.plist

파일의 마지막 쯔음에 아래의 문구를 추가해준다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CADisableMinimumFrameDurationOnPhone</key>
	<true/>
    ...

    <!-- 추가할 부분 -->
    <key>NSUserTrackingUsageDescription</key>
    <string>This identifier will be used to deliver personalized ads to you.</string>
</dict>
</plist>

그리고 ReadMe에 보면 아래와 같은 내용이 있다.

Google recommends that you should be using Google Mobile Ads SDK 7.64.0 or higher.
Google Mobile Ads SDK 7.64.0 이상을 사용할 것을 권장

근데 난 google_mobile_ads: ^3.0.0 라이브러리를 쓰고 있고, 크게 문제는 되지 않았다.
하지만 IDFA를 사용할 수 없을 때의 대비도 하는게 좋다고 한다.(다른 곳에서 찾은 정보)

IDFA(Identifier for Advertiser)는 참고로 광고 식별자를 의미한다.
애플은 이를 IDFA라 부르고, 안드로이드는 ADID라 부른다.
아주 예전에 애플이 이걸 막는다 하여 한때 큰 이슈였긴 했다.

여기서 다룰 문제는 아니고 IDFA를 사용할 수 없을 때 SKAdNetwork를 적용하는 방법은 아래와 같다.
위에 info.plist에 아래의 내용을 추가해준다.
위치는 하단이나 적당한 곳에?

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CADisableMinimumFrameDurationOnPhone</key>
	<true/>
    ...
    <key>NSUserTrackingUsageDescription</key>
    <string>This identifier will be used to deliver personalized ads to you.</string>

    <!-- 추가할 부분 -->
    <key>SKAdNetworkItems</key>
<array>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>cstr6suwn9.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>4fzdc2evr5.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>4pfyvq9l8r.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>2fnua5tdw4.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>ydx93a7ass.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>5a6flpkh64.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>p78axxw29g.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v72qych5uu.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>ludvb6z3bs.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>cp8zw746q7.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>3sh42y64q3.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>c6k4g5qg8m.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>s39g8k73mm.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>3qy4746246.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>f38h382jlk.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>hs6bdukanm.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v4nxqhlyqp.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>wzmmz9fp6w.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>yclnxrl5pm.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>t38b2kh725.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>7ug5zh24hu.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>gta9lk7p23.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>vutu7akeur.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>y5ghdn5j9k.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>n6fk4nfna4.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>v9wttpbfk9.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>n38lu8286q.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>47vhws6wlr.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>kbd757ywx3.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>9t245vhmpl.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>eh6m2bh4zr.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>a2p9lx4jpn.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>22mmun2rn5.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>4468km3ulz.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>2u9pt9hc89.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>8s468mfl3y.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>klf5c3l5u5.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>ppxm28t8ap.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>ecpz2srf59.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>uw77j35x4d.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>pwa73g5rt2.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>mlmmfzh3r3.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>578prtvx9j.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>4dzt52r2t5.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>e5fvkxwrpn.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>8c4e2ghe7u.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>zq492l623r.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>3rd42ekr43.skadnetwork</string>
  </dict>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>3qcr597p9d.skadnetwork</string>
  </dict>
</array>
</dict>
</plist>

이것에 대해 좀더 알고 싶은 분은 구글 공식 문서(개인 정보 보호 전략)를 참고하자.

2. 설치

위 작업이 끝났다면, 터미널을 열고 아래의 명령어를 주고 설치한다.

> flutter pub add app_tracking_transparency

3. 적용

자신의 프로젝트마다 다르겠지만, main.dart에 직접 위젯을 올려서 하는 프로젝트는 없을 것이다.
보통 별도 파일을 하나 만들고 거기서 또 위젯을 부르고 상태 관리를 하는게 일반적일 듯 싶다.

이걸 왜 설명하냐하면, 적용해야 할 곳이 앱이 처음 실행되는 위젯에서 구현해야 할 내용이 있기 때문이다.
StatefulWidget을 상속받은 Home이라는 위젯이 있는데 메인함수가 이 위젯을 호출한다.
build 메서드를 수행하기 전 오버라이드 된 initState 메서드를 아래와 같이 추가해준다.

  
  void initState() {
    super.initState();

    // It is safer to call native code using addPostFrameCallback after the widget has been fully built and initialized.
    // Directly calling native code from initState may result in errors due to the widget tree not being fully built at that point.
    WidgetsFlutterBinding.ensureInitialized()
        .addPostFrameCallback((_) => initPlugin());
  }

그리고 아래의 메서드를 추가해준다.


// Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlugin() async {
    try {
      final TrackingStatus status =
          await AppTrackingTransparency.trackingAuthorizationStatus;
      setState(() => _authStatus = '$status');
      // If the system can show an authorization request dialog
      if (status == TrackingStatus.notDetermined) {
        // // Show a custom explainer dialog before the system dialog
        // await showCustomTrackingDialog(context);

        // Wait for dialog popping animation
        // await Future.delayed(const Duration(milliseconds: 200));

        // Request system's tracking authorization dialog
        final TrackingStatus status =
            await AppTrackingTransparency.requestTrackingAuthorization();
        setState(() => _authStatus = '$status');
      }
    } on PlatformException {
      setState(() => _authStatus = 'PlatformException was thrown');
    }

    // final uuid = await AppTrackingTransparency.getAdvertisingIdentifier();
  }

참고로 status 값은 아래와 같은 형태로 선언되어 있다.

String _authStatus = 'Unknown';

4. 결과 및 기타

이렇게 하면 끝이다.

근데 공식 pub의 readme 하단에 보면 아래와 같은 문구가 있다.

IOS does not allow to display multiple native dialogs.
If you try to open a native dialog when there is already a dialog on screen, previous dialog will be forcefully closed by the system.
It's very common to show notification permission dialog on the first run of an ios application.
If you both try to show a notification permission dialog and app tracking request dialog, one of the each will be cancelled.
One way to handle this is using an explainer dialog before requesting tracking authorization. Please check the sample project for more on this. I highly recommend this approach.

IOS는 여러 개의 네이티브 대화상자를 표시할 수 없습니다.
화면에 이미 대화가 있을 때 네이티브 대화 상자를 열려고 하면, 이전 대화 상자가 시스템에 의해 강제로 닫힙니다.
ios 애플리케이션의 첫 번째 실행에 알림 권한 대화상자를 표시하는 것은 매우 일반적이다.
둘 다 알림 권한 대화상자와 앱 추적 요청 대화상자를 보여주려고 하면, 각각 중 하나가 취소됩니다.
이것을 처리하는 한 가지 방법은 추적 허가를 요청하기 전에 설명 대화를 사용하는 것이다.
이것에 대한 자세한 내용은 샘플 프로젝트를 확인하세요.
저는 이 접근 방식을 강력히 추천합니다.

즉 앱 시작할 때 플러터가 아닌 네이티브 다이얼로그가 여러 개가 뜰 상황엔 저 기능의 동작을 보장하지 않는다는 뜻이다.
그래서 예시 코드를 보면 알겠지만 어떤 함수를 먼저 호출해서 플러터에서 호출되는 다이얼로그가 뜬 뒤에 호출되도록 구현되어 있다.

Future<void> showCustomTrackingDialog(BuildContext context) async =>
      await showDialog<void>(
        context: context,
        builder: (context) => AlertDialog(
          title: const Text('Dear User'),
          content: const Text(
            'We care about your privacy and data security. We keep this app free by showing ads. '
            'Can we continue to use your data to tailor ads for you?\n\nYou can change your choice anytime in the app settings. '
            'Our partners will collect data and use a unique identifier on your device to show you ads.',
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('Continue'),
            ),
          ],
        ),
      );

이걸 사용하는것도 좋지만 난 그냥 바로 뜨게 했다.

암담한 결과 : 세 번째 거절

그렇다.
Guideline 2.1 - Information Needed 이슈로 리젝을 때렸다.
iOS 17.0.3에서는 메세지를 볼 수 없다는 뜻이다.

그래서 위에 방법도 해결할 수 있는 방법이지만, 구글에서 나와 같은 이슈를 다루는 페이지를 찾았다.

img05

일본어로 되어 있긴 한데 대충 무슨 뜻인지 알았고, 아래 원인에 보면,
Flutter 화면 표시 이후 ATT 다이얼로그 표시가 제대로 표시가 안된다.
깃헙 이슈에 이미 등록되어 있다고 한다.

나도 들어가서 봤더니 아주 간단한 트릭으로 해결했다고 한다.

img06

그렇다.
단순하게 1초 뒤에 띄우게끔 코드를 해뒀다.

///깃허브 이슈 구현방법

  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_){
      _trackingTransparencyRequest();
    });
  }

  Future<String?> _trackingTransparencyRequest() async {
    await Future.delayed(const Duration(milliseconds: 1000));

    final TrackingStatus status = await AppTrackingTransparency.trackingAuthorizationStatus;
    if (status == TrackingStatus.authorized) {
      final uuid = await AppTrackingTransparency.getAdvertisingIdentifier();
      return uuid;
    } else if(status == TrackingStatus.notDetermined) {
      await AppTrackingTransparency.requestTrackingAuthorization();
      final uuid = await AppTrackingTransparency.getAdvertisingIdentifier();
      return uuid;
    }

    return null;
  }

    ///일본 페이지 구현 방법
  Future _trackingTransparencyRequest() async {
    final TrackingStatus trackingStatus = await AppTrackingTransparency.trackingAuthorizationStatus;
    if(trackingStatus == TrackingStatus.notDetermined) {
        await Future.delayed(const Duration(milliseconds: 1000)); // 1秒遅らせる
        final status = await AppTrackingTransparency.requestTrackingAuthorization(); // ATTダイアログを表示する
    }
}

핵심은 저 1초 딜레이인데 호출 위치는 사실 큰 상관은 없을 것 같긴 하다.

정리 및 참고

그래서 이제 4번째 제출을 했다.

img07

과연 이번엔 리뷰 통과를 할 수 있을지...
통과가 되면 따로 글은 남기지 않겠지만...

만약 통과하지 못하면 해당 포스팅은 또 업데이트 될 예정이다...