他のアプリ画面上にアイコン画像などを表示させることが、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 |
if (Settings.canDrawOverlays(this)){ //... } else{ // 許可されていない Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivity(intent); // 設定画面に移行 launcher.launch(intent); } ActivityResultLauncher<Intent> launcher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { // 許可されたか再確認 if (Settings.canDrawOverlays(this)) { 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 17 |
int typeLayer = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; WindowManager.LayoutParams params = new WindowManager.LayoutParams ( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, typeLayer, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT); // dipを取得 int dpScale = (int)getResources().getDisplayMetrics().density; // 右上に配置 params.gravity= Gravity.TOP | Gravity.END; params.x = 20 * dpScale; // 20dp params.y = 80 * dpScale; // 80dp |
レイヤータイプとflagsは様々ありますのでWindowManager.LayoutParams Referenceを確認してください。
サンプルコード
常時表示するアプリとするためにServiceの startService() あるいは startForegroundService() を使い、画像としてImageViewのアイコン(少々大きめですが)にしました。
また、startしたServiceは停止させるようにしておかないとトラブルになりますので、この常駐アイコンをタッチすることにより停止できるようにします。
MainActivity.java
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 androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import android.content.Intent; import android.net.Uri; import android.provider.Settings; import android.widget.Button; import android.widget.Toast; public class MainActivity extends AppCompatActivity { private Intent intentService; private final ActivityResultLauncher<Intent> launcher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (Settings.canDrawOverlays(this)) { startForegroundService(intentService); } else{ Toast.makeText(getApplication(), R.string.message, Toast.LENGTH_LONG).show(); } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); intentService = new Intent(getApplication(), TestService.class); // Serviceを開始するためのボタン Button buttonStart = findViewById(R.id.button_start); buttonStart.setOnClickListener(v -> { if (Settings.canDrawOverlays(this)){ startForegroundService(intentService); } else{ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivity(intent); launcher.launch(intent); } }); } } |
Serviceが呼ばれたら、WindowManagerにViewを追加して表示させます。
TestService.java
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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
//package com.example.testwindowmanager; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.graphics.PixelFormat; import android.os.IBinder; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; public class TestService extends Service { private Context context; private NotificationManager notificationManager; private View newView; private WindowManager windowManager; @Override public void onCreate() { super.onCreate(); context = getApplicationContext(); notificationManager = (NotificationManager)context .getSystemService(Context.NOTIFICATION_SERVICE); // inflaterの生成 LayoutInflater layoutInflater = LayoutInflater.from(this); // レイアウトファイルからInfalteするViewを作成 final ViewGroup nullParent = null; newView = layoutInflater.inflate(R.layout.service_layer, nullParent); } @Override public int onStartCommand(Intent intent, int flags, int startId) { String channelId = "default"; String title = context.getString(R.string.app_name); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_MUTABLE); // Notification Channel 設定 NotificationChannel channel = new NotificationChannel( channelId, title , NotificationManager.IMPORTANCE_DEFAULT); if(notificationManager != null){ notificationManager.createNotificationChannel(channel); Notification notification = new Notification.Builder(context, channelId) .setContentTitle(title) // android標準アイコンから .setSmallIcon(android.R.drawable.btn_star) .setContentText("APPLICATION_OVERLAY") .setAutoCancel(true) .setContentIntent(pendingIntent) .setWhen(System.currentTimeMillis()) .build(); // startForeground startForeground(1, notification); } int typeLayer = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; windowManager = (WindowManager)getApplicationContext() .getSystemService(Context.WINDOW_SERVICE); WindowManager.LayoutParams params = new WindowManager.LayoutParams ( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, typeLayer, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT); // dipを取得 int dpScale = (int)getResources().getDisplayMetrics().density; // 右上に配置 params.gravity= Gravity.TOP | Gravity.END; params.x = 20 * dpScale; // 20dp params.y = 80 * dpScale; // 80dp // ViewにTouchListenerを設定する newView.setOnTouchListener((v, event) -> { Log.d("debug","onTouch"); if(event.getAction() == MotionEvent.ACTION_DOWN){ Log.d("debug","ACTION_DOWN"); } if(event.getAction() == MotionEvent.ACTION_UP){ Log.d("debug","ACTION_UP"); // warning: override performClick() newView.performClick(); // Serviceを自ら停止させる stopSelf(); } return false; }); // Viewを画面上に追加 windowManager.addView(newView, params); return super.onStartCommand(intent, flags, startId); } @Override public void onDestroy() { super.onDestroy(); Log.d("debug","onDestroy"); // Viewを削除 windowManager.removeView(newView); } @Override public IBinder onBind(Intent intent) { 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 18 19 20 |
<?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" /> <!-- API 28 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <application ... android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> ... </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 など、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 |
<resources> <string name="app_name">YourAppName</string> <string name="button">Start</string> <string name="description">image</string> </resources> |
Permissionを許可した後に、Serviceをスタートするとアイコンが表示されて、他のレイヤーの上位にいることがわかります。
startService()の場合は、しばらくするとシステムから終了させられてしまいます。startForegroundService()を使うとアプリ終了してもstatus barにアイコンが表示されてForegroundにあるアプリのようになり、シツコイ感もあります。尚、startForegroundService()はOreoからなので切り分けが必要です。
[モデル 河村友歌]
関連ページ:
References:
WindowManager.LayoutParams
SYSTEM_ALERT_WINDOW
ACTION_MANAGE_OVERLAY_PERMISSION
canDrawOverlays(android.content.Context)