AndroidのwipeDataメソッドで未知のSecurityExceptionエラーが発生する

Androidもいよいよ15が出てきて、「そろそろTargetSDK34対応させるかー」となる季節。皆様いかがお過ごしでしょうか<br>
今回、SDKのバージョンアップにあたってリファレンスに載ってないエラーに出会い、AOSPに潜る羽目になったので、情報共有とかを兼ねて記事を残しておきます
## 起こったこと
TargetSDKを33から34へ上げたところ、wipeDataというメソッドでエラーを吐くようになりました。デバイスを初期化させる機能の箇所で比較的シンプルなんですが…
エラー文は以下の通りです
```
SecurityException: java.lang.SecurityException: User 0 is a system user and cannot be removed
```

該当メソッドは[これ](https://developer.android.com/reference/android/app/admin/DevicePolicyManager#wipeData(int,%20java.lang.CharSequence))です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2699725/686eeec4-67a1-b6a4-e92b-c2e0a2284aa0.png)

リファレンスの``SecurityException``についての欄を見てみると、
>呼び出し元のアプリケーションが、DeviceAdminInfo#USES_POLICY_WIPE_DATAを使用するアクティブな管理者を所有しておらず、Manifest.permission.MASTER_CLEARまたはManifest.permission.MANAGE_DEVICE_POLICY_WIPE_DATAパーミッションが付与されていない場合。

とのことです。しかし、それはおかしいのです
なぜなら、wipeDataメソッドを使用する権限はアップデート前のSDK33から変わっておらずアプリ側でも変更していません。
なので<b>リファレンス通りの権限不足のエラーであればSDK33の時に動作していたことに説明がつかない</b>からです

## 調べてみる
ひとまず、問題の内容をまとめてみますと
 - SDKのバージョンアップでSecurityExceptionエラーができようになった
 - 該当メソッドのリファレンスにエラーについての記述がない

という感じです
調べてみると同じエラーを吐いている事例が2件見つかりました
デバイスを初期化させるためのコードでエラーという点も同じです

https://www.androidenterprise.community/t5/admin-discussions/could-not-factory-reset-android-14-devices/m-p/4133

https://stackoverflow.com/questions/78489085/android-14-exception-on-devicepolicymanager-wipedata-user-0-is-a-system-user

上記の回答などを見てみると、「SDK34で追加されたwipeDeviceを使えばうまくいったよ！」とありました。

リファレンスにも
>アプリが、どのユーザーのものかに関係なく、デバイス全体をワイプしたい場合は、代わりにwipeDevice(int)を使うべきである。

とあり、「メソッドをwipeDeviceに変更する」というのは有効性が高いかもしれません
しかしながら、<INS>私が知りたいのはSecurityExceptionエラーが起こる根本的な原因とエビデンスであり</INS>、<b>「変更したらエラーがなくなったしオッケー♪」なんてやってたら大変なことになります</b>

なんて高尚なこと言っていますが、調べてみても「原因不明」に落ちてしまいます
はたはた困った…

## AOSPの海を潜る
調べても調べても回答が見つからなかったので意を決してAOSPを見ることにしました
（AOSP見るの大好きなんですが、時間がかかるので最終手段としてます）

今回見るのは以下の3点
 - SDK33からSDK34で、wipeDataにどのような変更が加えられたのか
 - wipeDeviceは本当に代替として妥当なのか
 - なぜSecurityExceptionエラーが発生したのか

となります
なお、AOSPについてはAndroid13-release(SDK33) と Android14-release(SDK34) で比較してます

https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android13-release/core/java/android/app/admin/DevicePolicyManager.java

https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android14-release/core/java/android/app/admin/DevicePolicyManager.java

### SDK33からSDK34で、wipeDataにどのような変更が加えられたのか



SDK33(Android 13)
```JAVA
public void wipeData(int flags) {
        wipeDataInternal(flags, "");
    }
```


SDK34(Android 14)
```JAVA
@RequiresPermission(value = MANAGE_DEVICE_POLICY_WIPE_DATA, conditional = true)
    public void wipeData(int flags) {
        wipeDataInternal(flags,
                /* wipeReasonForUser= */ "",
                /* factoryReset= */ false);
    }
```

なんか内部のメソッドの引数が増えてるぞ…！
しかもファクトリーリセットって書いてある！
SecurityExceptionエラーについてでもう少し追うのですが、どうやらこの引数の追加が今季あの問題の根幹のようです。リファレンスに書いてくれ…

<br>

### wipeDeviceは本当に代替として妥当なのか
```JAVA
@RequiresPermission(value = MANAGE_DEVICE_POLICY_WIPE_DATA, conditional = true)
    public void wipeDevice(int flags) {
        wipeDataInternal(flags,
                /* wipeReasonForUser= */ "",
                /* factoryReset= */ true);
    }
```
ファクトリーリセットがtrueになっていて、これは以前のSDK33のwipeDataと同じ挙動となります
代替としてwipeDeviceを使うのは正しそう

<br>

### なぜSecurityExceptionエラーが発生したのか
AOSPを読んでいるとメモでエラー内容など書いてくれているんですが、今回のエラーはメモにすら書いてない…読みといていくしかない
wipeDataは、wipeDataInternalをコールしているのでそこを見る

```JAVA
private void wipeDataInternal(int flags, @NonNull String wipeReasonForUser,
            boolean factoryReset) {
        if (mService != null) {
            try {
                mService.wipeDataWithReason(mContext.getPackageName(), flags, wipeReasonForUser,
                        mParentInstance, factoryReset);
            } catch (RemoteException e) {
                throw e.rethrowFromSystemServer();
            }
        }
    }
```



wipeDataWithReasonを用いていることがわかるので、該当箇所を検索


```JAVA
@Override
    public void wipeDataWithReason(String callerPackageName, int flags,
            @NonNull String wipeReasonForUser, boolean calledOnParentInstance,
            boolean factoryReset) {
        if (!mHasFeature && !hasCallingOrSelfPermission(permission.MASTER_CLEAR)) {
            return;
        }
    ~~ 中略 ~~
        int userId = admin != null ? admin.getUserHandle().getIdentifier()
                : caller.getUserId();
        Slogf.i(LOG_TAG, "wipeDataWithReason(%s): admin=%s, user=%d", wipeReasonForUser, admin,
                userId);
    ~~ 中略 ~~
        event.write();
        String internalReason = String.format(
                "DevicePolicyManager.wipeDataWithReason() from %s, organization-owned? %s",
                adminName, calledByProfileOwnerOnOrgOwnedDevice);
        wipeDataNoLock(adminComp, flags, internalReason, wipeReasonForUser, userId,
                calledOnParentInstance, factoryReset);
    }
```
retuenする箇所以外は中略（ほとんど関係ないので）

注目したい点としてはuserIdは内部ロジックで自動的に取得している点と、条件分岐がなくメソッドの最後でwipeDataNoLockを行っている点。


```JAVA
private void wipeDataNoLock(@Nullable ComponentName admin, int flags, String internalReason,
            String wipeReasonForUser, int userId, boolean calledOnParentInstance,
            @Nullable Boolean factoryReset) {
    ~~ 中略 ~~
        boolean isSystemUser = userId == UserHandle.USER_SYSTEM;
        boolean wipeDevice;
        if (factoryReset == null || !mInjector.isChangeEnabled(EXPLICIT_WIPE_BEHAVIOUR,
                adminPackage,
                userId)) {
            // Legacy mode
            wipeDevice = isSystemUser;
        } else {
            // ファクトリーリセットがTrueだった時ーー
            if (factoryReset) {
                EnforcingAdmin enforcingAdmin = enforcePermissionsAndGetEnforcingAdmin(
                        /*admin=*/ null,
                        /*permission=*/ new String[]{MANAGE_DEVICE_POLICY_WIPE_DATA,
                                MASTER_CLEAR},
                        USES_POLICY_WIPE_DATA,
                        adminPackage,
                        factoryReset ? UserHandle.USER_ALL :
                                getAffectedUser(calledOnParentInstance));
                wipeDevice = true;
            // ファクトリーリセットがfalseだった時ーー
            } else {
                // userIDがシステムユーザであればSecurityExceptionを出す
                mInjector.binderWithCleanCallingIdentity(() -> {
                    Preconditions.checkCallAuthorization(!isSystemUser,
                            "User %s is a system user and cannot be removed", userId);
                    boolean isLastNonHeadlessUser = getUserInfo(userId).isFull()
                            && mUserManager.getAliveUsers().stream()
                            .filter((it) -> it.getUserHandle().getIdentifier() != userId)
                            .noneMatch(UserInfo::isFull);
                    Preconditions.checkState(!isLastNonHeadlessUser,
                            "Removing user %s would leave the device without any active users. "
                                    + "Consider factory resetting the device instead.",
                            userId);
                });
                wipeDevice = false;
            }
        }
        mInjector.binderWithCleanCallingIdentity(() -> {
            if (wipeDevice) {
                forceWipeDeviceNoLock(
                        (flags & WIPE_EXTERNAL_STORAGE) != 0,
                        internalReason,
                        (flags & WIPE_EUICC) != 0,
                        (flags & WIPE_RESET_PROTECTION_DATA) != 0);
            } else {
                forceWipeUser(userId, wipeReasonForUser, (flags & WIPE_SILENTLY) != 0);
            }
        });
    }
```

なので「wipeDataをコールしたユーザがシステムユーザであるとき、SecurityExceptionエラーを出す」ということのようです
今回初期化をする際はDeviceOwner権限で初期化していたので、これに該当したようです
システムユーザーかどうかは内部的に判断しているので、やはりwipeDeviceを使うしかなさそうだなぁ

## まとめ
<b>リファレンスに書いてくれ…！</b>
このような改修が入った背景としてはAndroid14でユーザー関係の操作が大きく変わって、User0がヘッドレスユーザとなったりするから変えたのかなと思います

https://developer.android.com/work/device-admin?hl=ja#headless-mode

しかしながらリファレンスに書いてないのは本当に困る本当に勘弁