他のアプリ画面上にアイコン画像などを表示させることが、ServiceとWindowManagerを組み合わせるとできます。
2021.2.1
SYSTEM_ALERT_WINDOW
通常、別のアプリが起動すると、当初あったアプリはバックグラウンドに移動させられてしまいます。ここではそのバックグラウンドから画像を表示させて、後から起動したアプリの上にかぶせてみようというものです。
Serviceなので確かにバックグラウンドで長期間のタスクを実行してくれますが、システムがユーザーに電池を消費していると知らせたりしますので、アプリをアンインストールされないように注意しましょう。
このアプリを作成するうえで仕様変更が続いています
- API 23からPermissionによるユーザーの許可が必要
- API 26からは使えるレイヤーがTYPE_APPLICATION_OVERLAYになりそれまでの上位レイヤーは非推奨
- API 28からはForeground Serviceでのパーミッシンが必要
- API 30からMANAGE_OVERLAY_PERMISSION インテントはユーザーを常にシステム権限の画面に移動させられるようになった
- API 31からstartActivityForResult()とonActivityResult()が非推奨
- API 31からフォアグラウンド サービスの起動が制限されるようになった
Settings.canDrawOverlays()
Settings.canDrawOverlays() からアプリに重ねて表示することが許可されているかを確認
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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{ // } } |
システム権限の「他のアプリに重ねて表示を許可」を尋ねる表示が出てきます。
面倒な操作が増えましたが、権限を付与する際の意識を高めることで
ユーザーを保護するのが目的だそうです
アプリ一覧から当該アプリを選択
許可を与える
許可した後は、バック2回でアプリ画面戻ります
AppOpsManagerを使うと1回にできるなど工夫はあるようです
TYPE_APPLICATION_OVERLAY
API level 26まではいくつかのレイヤーが使えたのですが、残念ながら使用することは非推奨となりました。代わりにTYPE_APPLICATION_OVERLAYを使います。これは通常のアプリの上に表示できますが、設定画面などはできません
レイヤー表示はWindowManager.LayoutParamsを使って設定していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
val typeLayer = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, typeLayer, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT ) // dpを取得 val dpScale = resources.displayMetrics.density.toInt() // 右上に配置 params.gravity = Gravity.TOP or Gravity.END params.x = 20 * dpScale // 20dp params.y = 80 * dpScale // 80dp |
レイヤータイプとflagsは様々ありますのでWindowManager.LayoutParams Referenceを確認してください。
サンプルコード
常時表示するアプリとするためにServiceの startService() あるいは startForegroundService() を使い、画像としてImageViewのアイコン(少々大きめですが)にしました。
また、startしたServiceは停止させるようにしておかないとトラブルになりますので、この常駐アイコンをタッチすることにより停止できるようにします。
MainActivity.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
//package com.example.testwindowmanager import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings import android.widget.Button import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { private lateinit var intentService: Intent private var launcher: ActivityResultLauncher<Intent> = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { if(Settings.canDrawOverlays(this)){ startForegroundService(intentService) } else{ Toast.makeText(applicationContext, R.string.message, Toast.LENGTH_LONG).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) intentService = Intent(application, TestService::class.java) val buttonStart: Button = findViewById(R.id.button_start) buttonStart.setOnClickListener { if (Settings.canDrawOverlays(this)){ startForegroundService(intentService) } else{ val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName") ) startActivity(intent) launcher.launch(intent) } } } } |
Serviceが呼ばれたら、WindowManagerにViewを追加して表示させます。
TestService.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
//package com.example.testwindowmanager import android.app.* import android.content.Context import android.content.Intent import android.graphics.PixelFormat import android.os.IBinder import android.util.Log import android.view.* class TestService : Service() { private lateinit var newView: View private lateinit var windowManager: WindowManager override fun onCreate() { super.onCreate() windowManager = applicationContext .getSystemService(WINDOW_SERVICE) as WindowManager // inflaterの生成 val layoutInflater = LayoutInflater.from(this) // レイアウトファイルからInfalteするViewを作成 val nullParent: ViewGroup? = null newView = layoutInflater.inflate(R.layout.service_layer, nullParent) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val context = applicationContext val channelId = "default" val title: String = context.getString(R.string.app_name) val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Notification Channel 設定 val channel = NotificationChannel( channelId, title, NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(channel) val notification = Notification.Builder(context, channelId) .setContentTitle(title) // android標準アイコンから .setSmallIcon(android.R.drawable.btn_star) .setContentText("APPLICATION_OVERLAY") .setAutoCancel(true) .setWhen(System.currentTimeMillis()) .build() // startForeground startForeground(1, notification) val typeLayer = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, typeLayer, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT ) // dpを取得 val dpScale = resources.displayMetrics.density.toInt() // 右上に配置 params.gravity = Gravity.TOP or Gravity.END params.x = 20 * dpScale // 20dp params.y = 80 * dpScale // 80dp // ViewにTouchListenerを設定する newView.setOnTouchListener { _, event -> Log.d("debug", "onTouch") if (event.action == MotionEvent.ACTION_DOWN) { newView.performClick() // Serviceを停止 stopSelf() } false } // Viewを画面上に追加 windowManager.addView(newView, params) return super.onStartCommand(intent, flags, startId) } override fun onDestroy() { super.onDestroy() Log.d("debug", "onDestroy") // Viewを削除 windowManager.removeView(newView) } override fun onBind(intent: Intent?): IBinder? { return null } } |
マニュフェストには、
- FOREGROUND_SERVICE のPermissionをAndroidManifestに記述
- SYSTEM_ALERT_WINDOWのPermissionの設定
- Serviceクラスの記述
AndroidManifest.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" ... <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <application ... <activity ... </activity> <service android:name=".TestService" /> </application> </manifest> |
MainActivityのレイアウトでは、サービスを起動するだけのボタンをとりあえずつくっておきます
activity_main.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:gravity="center" tools:context=".MainActivity"> <Button android:id="@+id/button_start" android:text="@string/button" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout> |
アプリ上に表示する画像を(ここでは umbrella.png)をdrawableに入れて、OverlayするViewのレイアウトを作成します。
今回はテストのためちょっと大きめの画像を張り付けてみます。
service_layer.xml
1 2 3 4 5 6 7 8 9 10 11 12 |
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content"> <ImageView android:src="@drawable/umbrella" android:layout_width="wrap_content" android:layout_height="wrap_content" android:contentDescription="@string/description"/> </RelativeLayout> |
strings.xml
1 2 3 4 5 6 |
<resources> <string name="app_name">TestWindowManager</string> <string name="button">Start</string> <string name="description">image</string> <string name="message">"「他のアプリの上に重ねて表示」からこのアプリに許可を与えてください"</string> </resources> |
Permissionを許可した後に、Serviceをスタートすると画像が表示されて、他のレイヤーの上位にいることがわかります。
関連ページ:
References:
WindowManager.LayoutParams
SYSTEM_ALERT_WINDOW
ACTION_MANAGE_OVERLAY_PERMISSION
canDrawOverlays(android.content.Context)