[android] 권한요청처리기

개요

안드로이드OS는 마시멜로(API 23)을 기준으로 런타임에 권한을 요청하게 됩니다. 따라서 앱이 특정권한을 획득하려면

  1. 매니페스트에 먼저 원하는 권한을 기술하고
  2. 위험한 권한인 경우 런타임에 추가적인 권한 승인을 받습니다.

여기서 우선 정상 권한과 위험한 권한에 대한 개념이 필요합니다. 걸려있는 링크를 참고하시면 자세한 한글 문서 나오는데 위험한 권한에 소속되어있는 경우 전부 런타임에 승인을 받아야 합니다.

위험한 권한이라고 일일히 승인을 받는 것은 아니고 각 권한들을 적당히 묶어 그룹을 형성해두었으므로 그룹 단위로 허가를 받게 됩니다.
예를 들어 위험한 권한 중에 주소록과 관련된 권한이 세 개가 있습니다.

  • READ_CONTACTS
  • WRITE_CONTACTS
  • GET_CONTACTS

헌데 이들 모두는 CONTACTS라는 권한 그룹에 소속된 권한입니다. 따라서 런타임에 유저에게 승인 받아야하는 항목은 한 개 뿐입니다. 왜냐면 런타임 권한 승인은 그룹 단위로 하기 때문입니다. 즉,

  • ‘주소록읽기 권한을 승인하시겠습니까?’
  • ‘주소록쓰기 권한을 승인하시겠습니까?’
  • ‘주소록얻기 권한을 승인하시겠습니까?’

이렇게 세 번을 묻지 않고 저 셋 중 아무거나 권한을 요청하면

  • ‘주소록접근 권한을 승인하시겠습니까?’

하나만 물어봅니다. 나머지 2개는 더 이상 묻지 않는거죠. 근데 이 작동이 미묘하게 오레오에서 차이가 있습니다.
오레오 이전까지는

  1. 매니페스트에 주소록읽기, 쓰기, 얻기를 기술해두고
  2. 런타임에 저 중 아무거나 권한요청을 하면
  3. 권한그룹에 대한 요청이 가서 유저가 승인하는 순간
  4. 매니페스트에 기술된 3개의 권한이 한꺼번에 승인됩니다.

즉 정리하자면 권한 요청은 주소록 읽기만 했는데 주소록 그룹이 승인되면서 읽기, 쓰기, 가져오기가 한꺼번에 승인이 되는 것입니다.

오레오도 읽기권한을 요청해서 주소록그룹을 승인하는데까지는 같습니다. 하지만 이 경우 오레오에서는 활성화된 권한은 오직 읽기 뿐입니다.
그럼 쓰기와 가져오기는? 다시 각각 요청해야 합니다. 하지만 요청한다고 이미 그 그룹을 승인한 마당에 고객에게 다시 승인창이 뜨지는 않습니다.
그냥 내부에서 자동승인됩니다. 차이점을 요약하면

  1. 오레오 이 전엔 그룹승인이 나면 매니페스트에 기술된 그룹 내 모든 권한이 한꺼번에 승인된다.
  2. 오레오는 하나의 권한 승인을 요청하면 권한그룹에 대한 승인이 이뤄지지만 여전히 요청한 한 개의 권한만 승인된다.
  3. 이후 그룹내의 다른 권한을 승인 요청해도 고객에게 승인창이 뜨지는 않지만 이렇게 각 권한을 따로따로 전부 요청해야 하만 한다.

좀 더 개발 상으로 귀찮아졌습니다(사실 고객에게 보여줄 것도 아니면서 왜 이렇게 변경되었는지 진의를 구글 문서를 읽어도 잘모르겠네요 ^^)

권한 요청 구현 시 고려해야 하는 사항

먼저 SDK의 버전을 확인하여 23 이전이라면 아예 이러한 절차를 진행할 필요가 없으니 먼저 막을 수 있을 것입니다.

if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;

난 주소록을 읽고 싶으니 주소록권한을 요청하자! 라고 간단히 될 거 같지만 이게 또 그렇지 않습니다. 권한요청을 할 때 고려해야하는 복잡한 요소가 숨어있죠.

우선 어떤 권한을 갖고 있는가 아닌가는 권한 그룹으로 확인하지 않고 개별 권한으로 확인합니다.

if(
  PackageManager.PERMISSION_GRANTED == 
    ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
) return;

위의 코드가 바로 해당 권한이 있는지 없는지 판별하는 건데 인자로 Context를 요구합니다. 권한이 있는 경우는 더 이상 권한요청을 안해도 되지만 유의할 점이 있습니다. 현재 안드로이드는 권한을 승인한 이 후에도 얼마든지 설정에 가서 권한을 취소할 수 있습니다. 즉 한 번 권한을 획득했다고 해서 안심할 수 없을 뿐더러 앱이 시작하는 시점에 확인해도 여전히 안심할 수 없습니다.
따라서 어떤 앱의 기능이 작동할 때 권한이 필요하다면 런타임에서 작동 전에 매번 확인해야 합니다. 뿐만 아니라 실행 중 권한 상실도 발생합니다. 이 부분을 좀 더 자세히 생각해보죠.

  1. 분명 카메라권한을 획득한 걸 확인하고 앱이 시작되었다.
  2. 그리고 카메라를 찍기 위한 액티비티가 열려 있다.
  3. 헌데 유저가 앱을 백그라운드로 돌리고 설정을 열었다.
  4. 설정에서 앱의 카메라권한을 해제했다.
  5. 다시 앱으로 돌아오면 카메라액티비티의 운명은?

이런 시나리오가 성립합니다. 이 시나리오에 대응하기 위해서는 onResume에 필요한 권한을 다시 체크하는 로직을 반드시 넣어야 합니다(실제 페이스북앱을 갖고 같은 실험을 해보면 앱이 재시작하고, 라인의 경우는 앱으로 돌아왔을 때 카메라는 중지되고 다시 권한을 요청합니다)

위 그림에서 왼쪽 화면을 보면 이미 카메라 뷰가 떠 있는 상태였지만 설정에서 카메라 권한 풀고 돌아가보면 팝업이 뜨는 걸 볼 수 있는데 onResume에서 수시로 권한체크를 하고 있다는 사실을 추측해볼 수 있습니다. 더 나아가 다시물어보지 않음을 선택하면 카메라뷰가 완전히 닫히고 토스트로 카메라 권한을 풀어달라고 뜨게 됩니다.

간단히 정리해보면 다음과 같습니다.

  1. 앱이 처음 시작할 때 확인하는 것으로는 아무것도 되지 않는다.
  2. 유저는 언제라고 불시에 권한을 취소할 수 있다.
  3. 따라서 그 기능을 사용해야하는 액티비티나 프레그먼트의 onResume에 권한체크를 넣어서 수시로 확인해야 한다.

권한처리기의 흐름

권한처리기를 구현함에 있어 큰 흐름을 살펴볼 필요가 있는데 마시멜로 이후의 처리로직을 이해하기 위해 다음과 같은 문서들을 참고해볼 수 있습니다.

  1. 런타임시의 권한요청(구글공식)
  2. 초보자를 위한 M 퍼미션입문(테크부스터)

구글 공식 가이드는 워낙 심플하고 기초적인 내용만 있어 현실세계의 복잡성을 이해하기엔 역부족이었습니다. 오히려 두번째 링크가 많이 도움이 되었습니다.
저 글에서 제시하는 흐름은 다음과 같습니다.

언틋봐서는 왜 이렇게 되는지 처음에 받아들이기 힘듭니다. 그래서 지인께서 추천해준 SafeDK의 권한처리에 관한 글에서 나오는 그림도 참고해보죠.

이 그림은 좀 내용이 간략하므로 설명하기 쉽습니다.

  1. 버전체크 해서 마시멜로 이상일 때 분기
  2. 해당 퍼미션이 있는지 체크
  3. shouldShowRequestPermissionRationale 을 이용해 안내 문구를 띄울 필요가 있는 확인
  4. 안내 문구를 띄울 필요가 없는 이유는 다음 셋중 하나다.
    1. 이전에 이 권한을 요청을 한 적이 한 번도 없다.
    2. 다시 묻지 않기를 체크해서 거부했다.
    3. 이 권한은 요청만 하면 자동승인 될 것이다.
  5. 4번이 어쨌든 requestPermissions으로 권한 요청을 날린다.
  6. OS의 권한 승인을 묻는 팝업이 뜰 것이다.
  7. 그 팝업에서 유저가 답변한 것은 onRequestPermissionsResult에 들어온다.
  8. 이 결과에 따라서 후처리를 한다.

이렇게 설명하는 것은 어렵지 않지만 이 그림에는 미묘한 점이 두 가지 숨어있습니다.

shouldShowRequestPermissionRationale 의 이상함

우선 4번 항목에서 안내 문구를 띄우지 않는 상황에 대한 세 가지 인데 굉장히 이상합니다.

  1. 처음 권한 요청하는 경우도 안내 문구는 띄워야 하는 게 정상이다.
  2. 다시 묻지 않기를 체크해서 거부한 것이라면 그 다음 절차는 요청하는게 아니라 거기서 멈추고 권한을 설정 가서 풀어 달라고 안내해야 한다.
  3. 게다가 기술적으로 이 시점에 다시 묻지 않기를 했기 때문에 안내 문구를 안 띄우는 상황인지 1, 3번 상황인지 알 수 있는 방법도 없다.

따라서 shouldShowRequestPermissionRationale 이 false인 경우 중 수긍이 가는 것은 자동 승인될 3번 경우 밖에 없습니다.
반대로 true인 경우라면 개발자가 작성한 안내문구를 보여줬을텐데 여기서 유저가 ‘그냥 라인에서 카메라기능을 안쓸테니 카메라 권한 허가 안할테야’ 라고 미리 결정해버리면 그 다음 단계인 requestPermissions로 갈 필요도 없죠.

onRequestPermissionsResult에 거부가 들어온 경우

그림에서 마지막에 거부당했으면 우아하게 처리해봐 따위 개드립을 하고 있는데 보다 구체적으로 살펴봐야 합니다.

  1. 다시 안내하고 꼭 허가해달라고 할거냐
  2. 근데 거부 자체를 다시 보지 않기를 체크해서 한거면 요청해봐야 유저에게 승인창이 보이지도 않는데 어쩔거냐
  3. 이 시점에서 그럼 그 권한이 필요한 뷰를 닫아버릴 거냐

이런 문제들이 산적되어 있습니다.

권한 처리의 흐름개선

문제를 점검해봤으니 위 그림을 한글로 번역한 걸 다시 볼까요.

이 그림에서 노란색이 바로 권한 체크 로직의 종단점들인데, 결국 문제가 되는 건 요청 전에 shouldShowRequestPermissionRationale 과 관련된 여러가지 처리입니다.
그에 비해 onRequestPermissionsResult에서 shouldShowRequestPermissionRationale 을 했는데 false가 되는 경우는 오직 다시 묻지 않기를 체크해서 거부한 경우 뿐입니다. 따라서 그림의 젤 마지막 상태는 3가지 중에 하나가 아니라 무조건 다시 묻지 않기로 거부당한 상태임을 확정지을 수 있습니다.

결국 요점은 shouldShowRequestPermissionRationale을 요청하기 전에 사용하는 것은 거의 의미가 없다는 것입니다. 그런 이유로 다음과 같이 개선합니다.

위 그림과 비교하여 요청 전의 shouldShowRequestPermissionRationale를 완전히 제거하고 권한이 필요한 이유를 직접 설명하여 이 수준에서 거부당하면 아예 요청을 하지 않도록 하고 onRequestPermissionsResult에서는 shouldShowRequestPermissionRationale를 이용해 다시 재귀적으로 반복할 것인지 정리할 것인지를 결정하도록 되어있습니다.

실제 코드의 작성

이제 흐름도가 나왔으므로 실제 코드를 구현할 차례입니다. 이러한 권한을 처리해줄 클래스를 PermissionRequest 라고 해두고 작성해보죠.
우선은 완성된 클래스를 사용하는 액티비티쪽의 코드부터 작성해보면서 인터페이스를 고민해 보겠습니다.

public class Act extends AppCompatActivity{
  @Override
  protected void onCreate(Bundle savedInstanceState){
    PermissionRequest.builder(this)
      .permissions(
         Manifest.permission.READ_CONTACTS, 
         Manifest.permission.CALL_PHONE, 
         Manifest.permission.READ_CALENDAR
      )
      .beforeRequest((permissions, resolver)->{
         Log.i("bs", "before:" + permission);
         resolver.ok();
      })
      .request(12);
  }
}

우선 다양한 조건이 올 수 있으니 인자의 복잡성을 피하기 위해 빌더 형식을 사용합니다.

  1. 어짜피 checkSelfPermission가 Context를 요구하므로 빌더 생성 시점에 Context를 인자로 받습니다.
  2. permissions메소드에서 필요한 만큼의 권한을 받아들입니다.
  3. beforeRequest는 requestPermissions를 직접 하기 전에 발동되며 이 때 람다에게 권한요청리스트 및 요청을 진행할지 말지를 결정해주는 resolve람다를 같이 보내줍니다. 반환 값으로 true, false를 보내지 않는 이유는 유저의 동의를 받는 등 람다 내부가 비동기일 가능성이 크기 때문입니다. 또한 단지 안내문구를 보여주는 것 외에도 다양하게 사용될 수 있으므로 보다 범위가 넓은 이름인 beforeRequest가 되었습니다.
  4. 마지막으로 request를 이용해 빌더를 발동시킵니다. 이때 넘기는 숫자는 onRequestPermissionsResult에서 어떤 요청인지 식별하는데 사용될 수 있습니다.

여기까지의 인터페이스를 바탕으로 직접 구현해보죠.

public class PermissionRequest{

  static public PermissionRequest builder(@NonNull AppCompatActivity act){
    return new PermissionRequest(act);
  }

  final private AppCompatActivity act;
  final private Set<String> permissions = new ConcurrentSkipListSet<>();
  private BeforeRequest beforeRequest;
  private int code;

  PermissionRequest(AppCompatActivity a){
    act = a;
  }
  1. 팩토리 함수와 생성자는 간단히 액티비티를 인자로 받아 속성으로 잡아둡니다.
  2. 실제 권한을 요청해야 하는 목록을 Set으로 잡아둡니다.
  3. 요청 전에 처리할 람다는 beforeRequest에 저장해둡니다.

이를 바탕으로 쉬운거부터 살살 구현해보죠.

permissions메소드

이건 아무것도 아니죠. 그저 permissions속성에 넣어주기만 하면 됩니다.

public PermissionRequest permissions(String...ps){
  for(String p:ps) permissions.add(p);
  return this;
}

beforeRequest메소드

우선 이 메소드를 구현하려면 두 개의 인터페이스를 정의해야 합니다.
하나는 인자로 받을 람다 형이고, 또 하나는 그 람다에 두 번째 인자로 전달할 resolver입니다. 이 두 개부터 정의하죠.

public interface BeforeRequest{
  void call(Set<String> permissions, BeforeResolver);
}

public class BeforeResolver{
  final private PermissionRequest checker;
  BeforeResolver(PermissionRequest c){
    checker = c;
  }
  public void ok(){
    checker.ok();
  }
}

두 개의 인터페이스가 있으니 실제 구현할 beforeRequest는 단순한 세터일 뿐입니다.

public PermissionRequest beforeRequest(BeforeRequest before){
  beforeRequest = before;
  return this;
}

BeforeResolver의 경우 생성 시점에 PermissionRequest를 받아 ok를 통해 실제 request를 진행할 수 있게 해주는 기능을 제공합니다.
BeforeRequest는 간단한 람다로 실제 요청하게될 permission 리스트와 리졸버를 줍니다. 이 리스트는 permissinos메소드에 설정한 리스트와 일치하지는 않습니다.
5개의 권한이 필요한데 이미 3개를 획득했다면 2개만 BeforeRequest에 오게 될 것입니다.
이러한 내용을 바탕으로 PermissionRequest의 마지막 빌드 메소드를 구현해보죠.

request와 ok메소드

이 메소드에서 최종적으로 빌더가 완성되면서 실제 작동을 하게 됩니다. 앞서 설명드린대로 인자로 받아들이는 int는 요청을 보낼 때마다 고유한 값을 이용해 onRequestPermissionsResult에서 어떤 요청에 대한 응답이 온 건지 식별할 수 있게 하는 기능입니다만, 사실 식별이 필요한 경우는 거의 없기 때문에 기본값으로 0을 보내도 무방하긴 합니다.

실제 request메소드의 핵심은 흐름도에서 그렸던 전반부 흐름처리 중 사용자에게 안내문구를 보여주는데 까지입니다. 다시 그림으로 보면 빨간 박스 영역이 일이 일어나게 되죠. 유저의 답변은 리졸버로 보고 받고 그 이후의 처리는 ok메소드가 대신하게 될 것입니다.

//기본값처리기
public void request(){request(0);} 

public void request(int c){
  //버전체크
  if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;

  //실제 권한이 있으면 제외하기
  for(String p : permissions){
    if(PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(act, p)){
      permissions.remove(p);
    }
  }

  //실제 요청할 권한이 없다면 종료
  if(permissions.size() == 0) return;

  //인자로 받은 코드를 기억해두고,
  code = c;

  //요청 전 처리 람다가 있으면 그쪽으로, 아니면 바로 ok로 간다.
  if(beforeRequest != null) beforeRequest.call(permissions, new BeforeResolver(this));
  else ok();
}

//실제 요청을 진행한다.
void ok(){
  ActivityCompat.requestPermissions(act, permissions.toArray(new String[0]), code);
}

이렇게 하여 PermissionRequest의 구현이 끝났습니다. 전체 코드를 보면 다음과 같을 것입니다.

public class PermissionRequest{

  public interface BeforeRequest{
    void call(Set<String> permissions, BeforeResolver);
  }

  static public class BeforeResolver{
    final private PermissionRequest checker;
    BeforeResolver(PermissionRequest c){checker = c;}
    public void ok(){checker.ok();}
  }

  static public PermissionRequest builder(@NonNull AppCompatActivity act){
    return new PermissionRequest(act);
  }

  final private AppCompatActivity act;
  final private Set<String> permissions = new ConcurrentSkipListSet<>();
  private BeforeRequest beforeRequest;
  private int code;

  PermissionRequest(AppCompatActivity a){act = a;}

  public PermissionRequest permissions(String...ps){
    for(String p:ps) permissions.add(p);
    return this;
  }

  public PermissionRequest beforeRequest(BeforeRequest before){
    beforeRequest = before;
    return this;
  }

  public void request(){request(0);} 
  public void request(int c){
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;
    for(String p : permissions){
      if(PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(act, p)){
        permissions.remove(p);
      }
    }
    if(permissions.size() == 0) return;
    code = c;
    if(beforeRequest != null) beforeRequest.call(permissions, new BeforeResolver(this));
    else ok();
  }
  void ok(){
    ActivityCompat.requestPermissions(act, permissions.toArray(new String[0]), code);
  }
}

onRequestPermissionsResult의 구현

이제 다시 액티비티측 코드로 돌아가서 onRequestPermissionsResult콜백이 어떻게 구현될지 먼저 인터페이스를 작성해보겠습니다.

public class Act extends AppCompatActivity{
  @Override
  protected void onCreate(Bundle savedInstanceState){
    PermissionRequest.builder(this)
      .permissions(
         Manifest.permission.READ_CONTACTS, 
         Manifest.permission.CALL_PHONE, 
         Manifest.permission.READ_CALENDAR
      )
      .beforeRequest((permissions, resolver)->{
         Log.i("bs", "before:" + permission);
         resolver.ok();
      })
      .request(12);
  }
  @Override
  public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults){
    PermissionMatcher.match(this, requestCode, permissions, grantResults)
      .is(Manifest.permission.READ_CONTACTS, isOk ->{
          Log.i("bs", "READ_CONTACTS:" + isOk);
      })
      .is(Manifest.permission.CALL_PHONE, isOk ->{
          Log.i("bs", "CALL_PHONE:" + isOk);
      })
      .rest(rest->Log.i("bs", "restPermission:" + rest))
      .denied(denied->{
        Log.i("bs", "deniedPermission:" + denied);
        return true;
      })
      .neverAsk(never->{
         new AlertDialog.Builder(this)
           .setTitle("설정가서 풀어줘")
           .setMessage("다시 묻지 않기 해서 기능 쓰려면 설정가서 풀어줘야 해")
           .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
              @Override
              public void onClick(DialogInterface dialogInterface, int i) {
                Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
                Uri uri = Uri.fromParts("package", getPackageName(), null);
                intent.setData(uri);
                startActivity(intent);
              }
           })
           .create()
           .show();
      });
  }
}

위 코드에서 요청 작성 시엔 빌더패턴으로 만들었지만 콜백에서는 매처(matcher) 스타일을 사용합니다. 이 매처가 처리할 내용은 그림 상의 아랫부분입니다.

사실 is메소드로 일일히 권한별로 살펴봐야 할 경우는 거의 없을 것입니다. rest에서는 is로 체크하지 않은 모든 권한이 들어오고, denied는 거부된 모든 권한목록이 들어옵니다. 헌데 denied의 경우 그림 상에서 보면 다시 요청 이전의 BeforeRequest를 호출할 수도 있을 것입니다. 이 경우는 비동기의 가능성이 없기 때문에 반환값으로 true는 BeforeRequest를 호출하고 false 호출하지 않는 식으로 작동합니다.
그에 반해 neverAsk는 별도로 분리해서 처리해야 하므로 토스트를 띄우거나 설정으로 보내줘야 할 것입니다. 위의 코드 샘플에서는 다이얼로그를 띄우고 설정으로 보내는 것까지 구현해봤습니다.
이제 실제 코드로 만들어보죠.

PermissionMatcher 클래스

우선 인자로 받게 되는 permissions와 grantResults 배열은 따로따로 들어오는데 짜피 인덱스로 비교해야하므로 초반에 인자로 받아들이면서 이 작업을 해버립니다.

public class PermissionMatcher{
   static PermissionMatcher match(AppCompatActivity act, int code, String[] permissions, int[] grantResults){
     return new PermissionMatcher(act, code, permissions, grantResults);
   }

   final private AppCompatActivity act;
   final private int code;
   final private Map<String, Boolean> permissions = new HashMap<>(), matchers;
   final private Set<String> denied = new HashSet<>(), never = new HashSet<>();
   private int noGranted = 0;
 
   PermissionMatcher(AppCompatActivity a, int c, String[] p, int[] r){
     act = a;
     code = c;
     for(int i = 0; i < p.length; i++){
       String key = p[i];
       boolean isGranted = r[i] == PackageManager.PERMISSION_GRANTED;
       permissions.put(key, isGranted);
       if(!isGranted){
         if(ActivityCompat.shouldShowRequestPermissionRationale(act, key)) denied.add(key);
         else never.add(key);
       }
     }
     matchers = new HashMap<>(permissions);
   }

shouldShowRequestPermissionRationale를 사용하려면 액티비티가 필요합니다. 생성자 인자로 액티비티와 요청코드를 받아들인 뒤 이 시점에 미리 허가여부와 단순 거부된 건지 아니면 다시 묻지 않기로 거부된 건지도 분리해둡니다.

is메소드

개별 권한별로 허가 여부를 반환하는 is메소드는 인자로 권한과 람다를 받는데 이 때 람다는 boolean을 받아들이는 void함수라 함수형 인터페이스에서 Consumer를 쓰면 딱이지만 안드로이드는 버전별로 지원되는 java클래스 라이브러리가 달라 안전하게는 직접 정의하는게 장땡입니다. 간단히 MatcherConsumer로 정의하고 is를 구현하죠.

public interface MatcherConsumer<T>{
  void accept(T v);
}

public PermissionMatcher is(@NonNull String permission, @NonNull MatcherConsumer<Boolean> f){
  if(matchers.containsKey(permission)){
    f.accept(matchers.get(permission));
    matchers.remove(permission);
  }
  return this;
}

is메소드 구현의 특이사항은 별게 없지만 한 번 is를 호출하면 matchers에서 빼내므로 rest를 계산할 수 있게 됩니다.

rest메소드

is를 구현했으니 손쉽게 rest를 구현할 수 있습니다만 rest도 전용 람다를 받아들여야 하므로 앞 서 정의한 MatcherConsumer를 재활용하여 작성합니다.

public PermissionMatcher rest(MatcherConsumer<Map<String, Boolean>> f){
  f.accept(matchers);
  return this;
}

denied메소드

denied는 is, rest에 영향을 받지 않고 거부된 권한만 모아서 처리할 수 있게 해줍니다. 이미 분류 작업은 생성자에서 해뒀습니다.
이 경우 받은 람다는 boolean을 반환해야 하므로 Function인터페이스가 매우 적합합니다. 안드로이드는 지원라이브러리를 사용하는 경우 android.arch.core.util.Function 패키지에 동일한 형태의 인터페이스가 존재합니다. 이를 사용하여 구현하면 됩니다. 하지만 문제는 true를 반환했을 때 어떻게 요청 시의 beforeRequest 콜백을 찾아 호출할 수 있는가가 문제입니다. 이를 위해서는 앞서 구현한 PermissionRequest에 static으로 인스턴스를 잡아두는 장치가 필요할 것입니다. 식별자는 requestCode를 활용하기로 하고 우선 이 부분부터 구현해보죠.

public class PermissionRequest{

  ...중략

  //코드를 식별자로 인스턴스 기억
  static final private Map<Integer, PermissionRequest> instances = new HashMap<>();

  void ok(){
    instances.put(code, this); //코드로 인스턴스를 잡아둔다.
    ActivityCompat.requestPermissions(act, permissions.toArray(new String[0]), code);
  }

  //코드기반으로 작동하는 before유틸메소드
  static void before(int code){
    if(!instances.containsKey(code)) return;
    instances.get(code).request(code);
  }
}

이 장치를 이용해 requestCode만 있으면 다시 재귀적으로 권한 허가를 요청할 수 있게 되었으므로 denied를 구현하면 됩니다.

public PermissionMatcher denied(@NonNull Function<Set<String>, Boolean> f){
  if(denied.size() > 0 && f.apply(denied)) PermissionRequest.before(code);
  return this;
}

neverAsk메소드

다시 그림으로 돌아와 보죠.

정확히 이 부분에 해당되는 경우를 처리하게 됩니다. 그림에 있는 그대로 거부된 권한 중 shouldShowRequestPermissionRationale이 false인 걸 한 번 더 추리면 됩니다.
인자로 받는 람다는 그저 거부된 목록만 주면 되고 반환은 필요없으니 아까 정의한 MatcherConsumer를 계속 재활용할 수 있습니다.

public PermissionMatcher never(@NonNull MatcherConsumer<Set<String>> f){
  if(never.size() > 0) f.accept(never);
  return this;
}

실제 구현된 코드는 denied와 비슷합니다.

결론

복잡한 마시멜로 이후의 권한 처리를 좀 단순화하고 쓰기 쉬운 인터페이스로 변경해봤습니다. 코드는 다음과 같습니다.

  • 액티비티에서 활용

    public class Act extends AppCompatActivity{
      @Override
      protected void onResume(){
        PermissionRequest.builder(this)
          .permissions(
             Manifest.permission.READ_CONTACTS, 
             Manifest.permission.CALL_PHONE, 
             Manifest.permission.READ_CALENDAR
          )
          .beforeRequest((permissions, resolver)->{
             Log.i("bs", "before:" + permission);
             resolver.ok();
          })
          .request(12);
      }
      @Override
      public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults){
        PermissionMatcher.match(requestCode, permissions, grantResults)
          .is(Manifest.permission.READ_CONTACTS, isOk->Log.i("bs", "READ_CONTACTS:" + isOk))
          .is(Manifest.permission.CALL_PHONE, isOk->Log.i("bs", "CALL_PHONE:" + isOk))
          .rest(rest->Log.i("bs", "restPermission:" + rest))
          .denied(denied->true)
          .neverAsk(this, never->Log.i("bs", "다시보지않기권한들:" + never));
      }
    }
    

  • PermissionRequest클래스

    static public class PermissionRequest{
    
      public interface BeforeRequest{
        void call(Set<String> permissions, BeforeResolver resolver);
      }
      static public class BeforeResolver{
        final private PermissionRequest checker;
        BeforeResolver(PermissionRequest c){checker = c;}
        public void ok(){checker.ok();}
      }
    
      static final private Map<Integer, PermissionRequest> instances = new HashMap<>();
      static void before(int code){
        if(!instances.containsKey(code)) return;
        instances.get(code).request(code);
      }
      static void beforeRemove(int code){
        if(instances.containsKey(code)) instances.remove(code);
      }
    
      static public PermissionRequest builder(@NonNull AppCompatActivity act){
        return builder(act, false);
      }
      static public PermissionRequest builder(@NonNull AppCompatActivity act, boolean isClear){
        if(isClear && instances.containsKey(code)) instances.remove(code);
        return new PermissionRequest(act);
      }
    
      final private AppCompatActivity act;
      final private Set<String> permissions = new ConcurrentSkipListSet<>();
      private BeforeRequest beforeRequest;
      private int code;
    
      PermissionRequest(@NonNull AppCompatActivity a){act = a;}
      public PermissionRequest permissions(String...ps){
        for(String p:ps) permissions.add(p);
        return this;
      }
      public PermissionRequest beforeRequest(BeforeRequest before){
        beforeRequest = before;
        return this;
      }
      public void request(){request(0);}
      public void request(int c){
        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;
        for(String p : permissions){
          if(PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(act, p)){
            permissions.remove(p);
          }
        }
        if(permissions.size() == 0) return;
        code = c;
        if(beforeRequest != null) beforeRequest.call(permissions, new BeforeResolver(this));
        else ok();
      }
      void ok(){
        instances.put(code, this);
        ActivityCompat.requestPermissions(act, permissions.toArray(new String[0]), code);
      }
    }
    

  • PermissionMatcher클래스

    static public class PermissionMatcher{
      public interface MatcherConsumer<T>{
        void accept(T v);
      }
      final private AppCompatActivity act;
      final private int code;
      final private Map<String, Boolean> permissions = new HashMap<>(), matchers;
      final private Set<String> denied = new HashSet<>(), never = new HashSet<>();
      private int noGranted = 0;
      PermissionMatcher(AppCompatActivity a, int c, String[] p, int[] r){
        act = a;
        code = c;
        for(int i = 0; i < p.length; i++){
          String key = p[i];
          boolean isGranted = r[i] == PackageManager.PERMISSION_GRANTED;
          permissions.put(key, isGranted);
          if(!isGranted){
            noGranted++;
            if(ActivityCompat.shouldShowRequestPermissionRationale(act, key)) denied.add(key);
            else never.add(key);
          }
        }
        matchers = new HashMap<>(permissions);
        if(noGranted == 0) PermissionRequest.beforeRemove(c);
      }
      public PermissionMatcher is(@NonNull String permission, @NonNull MatcherConsumer<Boolean> f){
        if(matchers.containsKey(permission)){
          f.accept(matchers.get(permission));
          matchers.remove(permission);
        }
        return this;
      }
      public PermissionMatcher rest(@NonNull MatcherConsumer<Map<String, Boolean>> f){
        if(matchers.size() > 0) f.accept(matchers);
        return this;
      }
      public PermissionMatcher denied(@NonNull Function<Set<String>, Boolean> f){
        if(denied.size() > 0 && f.apply(denied)) PermissionRequest.before(code);
        return this;
      }
      public PermissionMatcher never(@NonNull MatcherConsumer<Set<String>> f){
        if(never.size() > 0) f.accept(never);
        return this;
      }
    }