2021/05/20

non-SDK interfaces を veridex toolで検出する

SDKに定義されていない非公開なインタフェース(non-SDK interface)であってもリフレクションを使うことでアプリから参照することができていましたが, Android 9(API Lv.28)以降は制限されるようになりました. 詳細は「Improving Stability by Reducing Usage of non-SDK Interfaces」を参照してください.
この変更により, アプリはTarget SDKのバージョンを変更する際にはアプリや依存するライブラリがnon-SDK interfaceを使っていないかをチェックする必要があります.

チェックする方法はいくつかありますが, 本稿ではveridex toolを使用する方法についてまとめます. 「Android Developers - Restrictions on non-SDK interfaces
」にも方法が書かれてありますveridex toolsの制限など知りたい方はそちらを参照してください.

non-SDK interface使用箇所の検出

今回はmacOSでveridex toolsを実行します.

veridex toolsの実行

  1. gitからappcompatのtar.gzをDLします.
  2. tar.gzを展開すると veridex-mac.zip があるのでこれを展開します
  3. appcompat.sh があるのでこれを実行します
./appcompat.sh --dex-file=[APKファイルパス]

出力結果の確認

appcompat.shを実行すると下記のようなフォーマットで結果が出力されます.

#1: Linking unsupported Llibcore/io/Memory;->pokeByte(JB)V use(s):
       Lcom/google/android/gms/internal/gtm/zztx$zzb;->zza(JB)V

#2: Reflection max-target-p Landroid/widget/AutoCompleteTextView;->ensureImeVisible use(s):
       Landroidx/appcompat/widget/SearchView$PreQAutoCompleteTextViewReflector;-><init>()V

...

83 hidden API(s) used: 28 linked against, 55 through reflection
    70 in unsupported
    0 in blocked
    1 in max-target-o
    12 in max-target-p
    0 in max-target-q
    0 in max-target-r

結果の見方は下図の通りです.

non-SDK APIリストの種別

non-SDK APIリストの種別で unsupported は現在, 特に使用制限はなく, アプリが使用できるnon-SDK interfaceです.

max-target-p はAndroid 9では制限されていなかったが, Android 10から制限されるようになったAPIです. Android 9では問題ありません. しかし, Android 10かつTarget SDKバージョン10のアプリはこのAPIを使うことができません. 同条件でこのAPIを呼び出すと実行時例外が発生します.

検出されたnon-SDK interface

制限の対象となり得るnon-SDK interfaceです.

non -SDK interfaceの使用元

non-SDK interfaceをリフレクションを使って参照している参照元です.

検出されたnon-SDK interfaceの参照元をチェック

検出された参照元が自アプリのコードなら, 下記のようにSDKバージョンに応じて処理を分けるようにします.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {...}

検出された参照元が3rd-partyライブラリのコードならそのコードを参照し, SDKバージョンに応じて処理を分けているか確認します.

例えば, 下記のような出力結果が得られた場合,

#2: Reflection max-target-p Landroid/widget/AutoCompleteTextView;->ensureImeVisible use(s):
       Landroidx/appcompat/widget/SearchView$PreQAutoCompleteTextViewReflector;-><init>()V

androidx.appcompat.widget.SearchView$PreQAutoCompleteTextViewReflector のコードを確認します.

static final PreQAutoCompleteTextViewReflector PRE_API_29_HIDDEN_METHOD_INVOKER =
        (Build.VERSION.SDK_INT < 29) ? new PreQAutoCompleteTextViewReflector() : null;

SDKバージョンが考慮されているので, この出力結果は問題ないことがわかります.

もしライブラリ側に問題があった場合, ライブラリのバージョンを上げるか, コードオーナーに修正を依頼するなどして対応を待つ必要があります.

以上です.

2021/02/27

NavHostFragmentをFragmentの入れ子にする時はsetPrimaryNavigationFragmentを指定する

NavHostFragmentapp:defaultNavHost=trueを指定すればバックキー制御をNavHostFragmentに任せることができます.

    <androidx.fragment.app.FragmentContainerView
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost="true"
        ...

NavHostFragmentをアクティビティのレイアウトに指定した時の構造は次の通りです.

Activity
  |- NavHostFragment

一方で, アクティビティ直下にNavHostFragmentを配置せず, 下記のように間にフラグメントがいる場合は注意が必要です.

Activity
  |- Fragment
     |- NavHostFragment

この場合, フラグメントのレイアウトでapp:defaultNavHost=trueを指定しても, バックキー制御などナビゲーション周りで意図しない動作となります.

解決策

NavHostFragmentを持つフラグメントを PrimaryNavigationFragment に設定します.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

FragmentTransaction.setPrimaryNavigationFragment

app:defaultNavHost

NavHostFragmentapp:defaultNavHost=trueを指定されると, 自身のonAttachで同様に setPrimaryNavigationFragment(this) を設定します.

NavHostFragment.ktの該当行 - GitHub

setPrimaryNavigationFragment

プライマリナビゲーションフラグメントに指定されると, バックナビゲーションなどをハンドリングできるようになります.
app:defaultNavHost=trueを指定するだけで, NavHostFragmentがバックナビゲーションをうまく制御できるのはこのためです.

プライマリナビゲーションフラグメントはフラグメントマネージャのインスタンス毎に1つしか設定できません.

FragmentManager.setPrimaryNavigationFragment - GitHub

NavHostFragmentを複数管理する場合, app:defaultNavHost=trueNavHostFragmentは1つにしなければならない理由でもあります.

フラグメントがプライマリナビゲーションフラグメントと判定されるには, 親フラグメントがいる場合, 関連するフラグメントマネージャのプライマリナビゲーションフラグメントに指定されている必要があります.

つまり, 次の構造ではNavHostFragmentの親フラグメント/親フラグメントマネージャがいないので, NavHostfragmentがプライマリナビゲーションフラグメントになります.

Activity
  |- NavHostFragment

しかし, 次の構造ではNavHostFragmentに親フラグメントがおり, その親がsetPrimaryNavigationFragmentとして指定されていない場合, 子であるNavHostFragmentもプライマリナビゲーションフラグメントの条件を満たしません.

Activity
  |- Fragment
     |- NavHostFragment

そのため, 親フラグメントは次のようなコードで自身をプライマリナビゲーションフラグメントとして指定する必要があります.

class HostFragment : Fragment {
    override fun onAttach(context: Context) {
        super.onAttach(context)
        parentFragmentManager.commit {
            setPrimaryNavigationFragment(this@HostFragment)
        }

蛇足: NavHostFragmentのバックナビゲーション周りの実装

// デフォルトでフラグメントマネージャのOnBackPressedCallbackはenable=falseになっている

FragmentManager
    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
                @Override
                public void handleOnBackPressed() {
                    FragmentManager.this.handleOnBackPressed();
                }
            };

---

// OnBackPressedCallbackをenable=trueにするにはisPrimaryNavigationでtrueを返す必要がある

    private void updateOnBackPressedCallbackEnabled() {
        ...
        // This FragmentManager needs to have a back stack for this to be enabled
        // And the parent fragment, if it exists, needs to be the primary navigation
        // fragment.
        mOnBackPressedCallback.setEnabled(getBackStackEntryCount() > 0
                && isPrimaryNavigation(mParent));
    }

---

// PrimaryNavigationFragmentに変更があると...

Fragment
    void performPrimaryNavigationFragmentChanged() {
        boolean isPrimaryNavigationFragment = mFragmentManager.isPrimaryNavigation(this); ⭐️
        // Only send out the callback / dispatch if the state has changed
        if (mIsPrimaryNavigationFragment == null
                || mIsPrimaryNavigationFragment != isPrimaryNavigationFragment) {
            mIsPrimaryNavigationFragment = isPrimaryNavigationFragment;
            onPrimaryNavigationFragmentChanged(isPrimaryNavigationFragment); 🍣

---

// 一方, NavHostFragmentでは...

NavHostFragment
🍣
    public void onPrimaryNavigationFragmentChanged(boolean isPrimaryNavigationFragment) {
        if (mNavController != null) {
            mNavController.enableOnBackPressed(isPrimaryNavigationFragment); 🌴

---

// BackPressedCallbackを有効にするにはisPrimaryNavigationFragmentがtrueである必要がある.

NavController
🌴
    void enableOnBackPressed(boolean enabled) {
        mEnableOnBackPressedCallback = enabled;  
        updateOnBackPressedCallbackEnabled();

    private void updateOnBackPressedCallbackEnabled() {
        mOnBackPressedCallback.setEnabled(mEnableOnBackPressedCallback
                && getDestinationCountOnBackStack() > 1);

---

// NavControllerはOnBackPressedCallbackを持っている. NavHostFragmentのバックキー制御はNavControllerの責務

    private final OnBackPressedCallback mOnBackPressedCallback =
            new OnBackPressedCallback(false) {
        @Override
        public void handleOnBackPressed() {
            popBackStack();
        }
    };

以上.