March 31, 2022

AndroidのSYSTEM_ALERT_WINDOWの歴史

Androidには初期の頃から他のアプリの上にも独自のViewを表示できる素晴らしい機能SYSTEM_ALERT_WINDOWの機能があります。
Facebook Messangerのメッセージ受信時に表示されるオーバーレイもこの機能を使って実装されています。(現在はおそらく違う。後述) PiPが無い時代もこの機能を使ってYouTubeや動画を画面に重ねていつでも見れるアプリを作ってる友人がいました。
他のアプリを開いている時にも任意のViewを重ねられるので、Twitterを見ながら動画を見るなどができてSYSTEM_ALERT_WINDOWが実装されているアプリは同類のアプリと差別化できていたり、便利なものが多々ありました。

しかしながら、他のアプリの上に任意のViewを重ねられるというのはセキュリティ上の問題があります。
バージョンアップ毎に制限が厳しくなっていっているSYSTEM_ALERT_WINDOWの対応の歴史についてAndroid1.6時代からフィルターアプリを公開してバージョンアップの度に対応に追われている私が解説します。

(画面暗くしたりするのに便利なので良かったら使ってみてください。NotificationエリアのTileからOn/Offできるので個人的に重宝してます。)

Privacy Filter Free -Cut Blue - Apps on Google Play

This app reduces screen brightness and blue light.

基本

画面に重ねる基本のコードはこのようになっていて、WindowManagerに表示したいViewをaddViewしてServiceからこのコードを起動すると、どの画面でも好きなViewを表示できます。

val params = WindowManager.LayoutParams(
    width,
    height,
    WindowManager.LayoutParams.TYPE_SYSTEM_ALERT,
    WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    PixelFormat.TRANSPARENT
)

windowManager.addView(view, params)

4.0以降

1.xや2.x時代はパーミッションが不要だったのですが、Android4.0からはmanifestに

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

のパーミッションが必要になります。

コードは4以前のものがそのまま使えます。

6.0以降

Marshmallowの時代になってくるとAndroidでもiOS同様に各機能ごとにパーミッションが必要になり、オーバーレイの機能もユーザによる権限が必要になりました。

ちなみに、権限がない状態でWindowManagerに追加するとandroid.view.WindowManager$BadTokenException が発生してアプリが落ちます。
そのため適切な権限が付与されていない場合はコード側でチェックし設定画面に飛ばすなどして権限が必要なコードを呼び出さないようにしなければなりません。

権限周りの実装方法はAndroid Developersに詳しく載っています。

私はPermissions Dispatcher を使っています。

8.0以降

TYPE_SYSTEM_ALERTはDeprecatedになってTYPE_APPLICATION_OVERLAYに置き換わりました。
後方互換を維持する場合はバージョンでTypeの値を変える必要があります。

val params = WindowManager.LayoutParams(
    width,
    height,
    when {
      Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
      else -> WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY
    },
    WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    PixelFormat.TRANSPARENT
)

windowManager.addView(view, params)

ちなみに8.0以降でTYPE_SYSTEM_ALERTを使うとjava.lang.RuntimeException: Unable to start service になります。

他のアプリの上に重ねる場合は、WindowManagerの処理も変更する必要があるのですが、他のアプリの上に重ねる場合はForegroundServiceで実行しなければならなくなりました。 具体的にはNotificationのエリアを使って該当アプリが動作していることを明示的に表示する必要があります。 Foregroundで実行しない場合はOS側にKillされてしまいます。

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
startForeground(0, notificationBuilder.build())

foregroundを呼ばないと android.app.RemoteServiceException: Context.startForegroundService() did not then call Service.startForeground()が発生します。

12以降

しばらく変更はなかったのですがAndroid 12で大幅に制限 されるようになりました。

オーバーレイが安全でない方法でアプリを覆い隠している場合にタッチイベントをアプリが使用することを許しません

私が作っていたアプリでは FLAG_NOT_TOUCHABLE を使っていて、他のアプリの上でもオーバーレイしたViewを貫通してタッチイベントを下に伝えていたので動作しなくなってしまいました。
Androidも年々制限が厳しくなっていきますね…

ただし例外が用意されていて以下のいずれかの条件であれば動作します。

  • アプリ内の操作
  • 信頼済みのウィンドウ
    • ユーザー補助機能のウィンドウ
    • インプット メソッド エディタ(IME)のウィンドウ
    • アシスタントのウィンドウ
  • 不可視のウィンドウ
    (Root ViewがGONEまたはINVISIBLE)
  • 完全に透明なウィンドウ
    alpha プロパティが0.0
  • 十分に透明なシステムアラートウィンドウ 最大不透明度は0.8

この中で実用的な対応策は2つあって

  • WindowManagerの透明度を80%以下にする方法
  • Accessibility Serviceの許可を取って表示する方法

です。

対応策1: WindowManagerの透明度を80%以下にする

一番簡単なのはWindowManager自体の透明度を変更することです。追加するView自体の透明度ではないので注意してください。
透明度の最大値が変わってしまうので一部のアプリでは要件が満たせなくなるかもしれませんが、これだけ対応すれば動作自体は問題ないです。

val params = WindowManager.LayoutParams(
    width,
    height,
    when {
      Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
      else -> WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY
    },
    WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    PixelFormat.TRANSPARENT
)
params.alpha = 0.8f // 追加
windowManager.addView(view, params)

InputMangerからMAXの透明度を取得できるようにもなったので、今後アップデートで閾値が変わって動かなくなる可能性あることを考えると直接指定するよりはこちらの方が良いと思います。

params.alpha =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) (getSystemService(Context.INPUT_SERVICE) as InputManager).maximumObscuringOpacityForTouch
    else 0.8f

対応策2: Accessibility Serviceを使う

オーバーレイの許可に加えてアクセシビリティの許可が要るためユーザからすると二度手間になってしまうのですが、今まで通りの動作を望むのならこの方法が確実です。

まずは res/xml に設定ファイルを追加します。

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackAllMask"
    android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagIncludeNotImportantViews"
    android:canRetrieveWindowContent="true"
    android:notificationTimeout="100"
    android:packageNames="app.aakira.example"
    />

次に該当ServiceをManifestに追加します。

<service
    android:name=".ExampleService"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:exported="false"
    >
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config"
        />
</service>

最後にWindowManagerをAccessibilityServiceに実装してあげるとオーバーレイ表示が可能になります。(Foregroundの処理は書いていないので注意してください)

class ExampleService: AccessibilityService() {

    private val windowManager by lazy { (getSystemService(Context.WINDOW_SERVICE) as WindowManager) }

    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    }

    override fun onInterrupt() {
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        val params = WindowManager.LayoutParams(
            500,
            500,
            WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
            WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
            PixelFormat.TRANSPARENT
        )
        val view = View(baseContext)
        view.setBackgroundColor(Color.RED)
        view.layoutParams = LinearLayout.LayoutParams(500, 500)
        windowManager.addView(redView, params)

        return START_STICKY
    }
}

まとめ

Android12でもAccessibility Serviceを使ったりしてTYPE_APPLICATION_OVERLAYの表示ができました。
無法地帯とも言えたAndroidもセキュリティの強化により年々規制が厳しくなりつつあります。特にこのTYPE_APPLICATION_OVERLAY は他のアプリに重ねることができるため悪用しやすく今後もセキュリティ対策の影響を受けることが予想されます。
このAPI自体はいろいろな発想ができてiOSにはないとても良いAPIだと思うので残って欲しいなーと思っています。

おまけ

ユーザー数の多いアプリだからなのかAndroid10からはBubble APIというのが用意されるようになりました。
現状のFacebook Messangerはこの方法で実装されていると思います。 結構限定的な使い方というかサードパーティアプリから公式に用意された珍しいAPIな気がします

© AAkira 2023