【Android】他のアプリの上に重ねて表示するアプリの作り方

記事内に広告が含まれています。

Androidの世界では基本的に1アプリのみが前面に表示されます。

ですが、アプリを開発するとき、画面上に常に情報を表示させたいというようなケースもあるかと思います。

そんな場合には、 android.permission.SYSTEM_ALERT_WINDOW を宣言することで他のアプリが前面の場合でも 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)

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

Android 6.0未満

Android 6.0までは制限は特になく、AndroidManifest.xml に android.permission.SYSTEM_ALERT_WINDOW を宣言するだけで利用可能でした。

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

Android 6.0以上

攻撃者がこの権限を悪用し、ランサムウェアなどで常に画面を前面表示することができてしまうため、Android 6.0からはユーザの明示的な許可が必要になりました。

ただし、Android 6.0.1以上でGoogle Playアプリ6.0.5以上が入っている場合、Google Playからインストールしたアプリは「他のアプリの上に重ねて表示」の権限がデフォルトオンになります。恐らくGoogle的にはGoogle Playにあるアプリは安全という前提があるのでしょう。

この権限はRuntime Perissionではなく設定の奥にあるのでIntentを飛ばし、 onActivityResult() でハンドリングします。

val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMIISSION)

権限がない状態で WindowManager に追加すると例外が発生してアプリが落ちます。
そのため、権限が付与されているかどうかソースコードでチェックして処理を行うようにしなければなりません。

設定が有効かどうかは Settings.canDrawOverlays() を使って確認します。

if (Settings.canDrawOverlays(this)){
    //,,,
}
else{
    // 許可されていない
    val intent = Intent(
        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
        Uri.parse("package:$packageName")
    )
    startActivity(intent)
    // 設定画面に移行
    launcher.launch(intent)
}
 
private var launcher: ActivityResultLauncher<Intent> = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) {
    // 許可されたか再確認
    if(Settings.canDrawOverlays(this)){
        // Serviceに跳ぶ
        startForegroundService(intentService)
    }
    else{
        //
    }
}

Android 8.0以上

targetSdkVersion 26以上にてViewを表示するときの指定で TYPE_APPLICATION_OVERLAY を使わなければいけなくなりました。

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)

これによりシステムUIなどの重要なViewの上に描画できなくなり、アプリインストール時に権限の上にViewを表示して隠すなどの攻撃が不可能になりました。

Android 8.0では権限取得にバグがあり、変更がすぐに反映されないので監視する必要があります。

Android 8.1では修正済みです。

他のアプリの上に重ねる場合は、WindowManager の処理も変更する必要があるのですが、他のアプリの上に重ねる場合は ForegroundService で実行しなければならなくなりました。

具体的には Notification のエリアを使って該当アプリが動作していることを明示的に表示する必要があります。Foregroundで実行しない場合はOS側にKillされてしまいます。

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

foregroundを呼ばないと例外が発生します。

Android 12以降

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

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

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
    }
}

参考サイト

[Android & Kotlin] アプリの上に重ねて画像を表示
他のアプリ画面上にアイコン画像などを表示させることが、ServiceとWindowManagerを組み合わせるとできます。

コメント

タイトルとURLをコピーしました