AndroidでToggleButtonを用いてServiceを起動・終了させてみる(起動時に通知バーに表示させる)

# はじめに

Androidアプリ開発(javaも)を触るのはかなり久々で何も覚えていなかったのですが、ちょっと思い立ってアプリを作ってみようかと思って奮闘中です。あまり詳しくないので細かい解説は出来ませんしn番煎じ感が否めませんが、ログとして残す意味でも書いておきます。
昔は~~結構重かった~~Eclipseを使ってAndroid用のプラグインやらAVD入れてゴチャゴチャやって・・・って感じでしたが、今ではAndroid studioでレイアウトもGUIで直感的に作れるようになって、エミュレータやら外部端末の設定も楽々で、サクサクになっていて便利になりましたね。

# やりたいこと

ある一定間隔で何らかの処理をさせるAndroidアプリを開発したいと思っています。勝手に死んで欲しくはないので、ここでは`IntentService`ではなく、`Service`を使おうと思いました。
サービスの開始・停止には`ToggleButton`を使用します。

# 環境について

- 開発環境はAndroid studio 3.5.2
- テストを走らせる端末はAndroid 8.0.0 & 9
- 言語はJava 

# 実装

## レイアウトについて

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:context=".MainActivity">

    <ToggleButton
        android:id="@+id/service_toggle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="ToggleButton"
        android:textOff="サービス開始"
        android:textOn="サービス終了"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
```
作り方は簡単です。
`activity_main.xml`を開くとレイアウトをGUIで作れるDesignモードで開けるので、Palletteの中からButtons->ToggleButtonを選択します。On / Off時の表示させるテキストは右側Attributesから編集出来ます。

## MainActivityについて
ここでは、主にToggleButtonの処理を行います。

```MainActivity.java
package com.example.testproject;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;
import android.widget.CompoundButton;
import android.widget.ToggleButton;

public class MainActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {

    ToggleButton service_toggle;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        service_toggle = (ToggleButton)findViewById(R.id.service_toggle);
        service_toggle.setChecked(TestService.isActive);
        service_toggle.setOnCheckedChangeListener(this);
    }

    @Override
    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
        if (isChecked) {
            startService(new Intent(MainActivity.this, TestService.class));
        } else {
            stopService(new Intent(MainActivity.this, TestService.class));
        }
    }
}
```
serviceを起動するToggleButton`service_toggle`をレイアウトと紐付けます。後に出てくるTestServiceが起動中かを取得して、その結果をToggleに反映させます(`service_toggle.setChecked(TestService.isActive);`)。
こうすることによって、例えばActivityが破棄された状態でもバックグラウンドでServiceが起動していればToggleがOnになるように出来ます。

implementしている`CompoundButton.OnCheckedChangeListener`の`onCheckedChanged`をOverrideして、Toggleを操作した時にServiceへIntentを送ります。

## Serviceについて
ここでは具体的にバックグラウンドで処理させたいものを書きます。
今回はServiceのStart / Stop時にToastを出力させ、Serviceの実行中は1秒毎にLogを出力させるようにします。

```TestService.java
package com.example.roadmemo;

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.os.Handler;
import android.os.IBinder;
import android.util.Log;

import android.widget.Toast;

import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

import static android.widget.Toast.*;

public class TestService extends Service {
    static boolean isActive = false;
    final Handler handler = new Handler();
    final Runnable runnable = new Runnable() {
        Integer count = 0;
        @Override
        public void run() {
            ++count;
            Log.d("LocalService", "runnable" + count.toString());
            handler.postDelayed(this, 1000);
        }
    };

    @Override
    public void onCreate() {
        super.onCreate();;
        Log.d("LocalService", "onCreate");
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Context context = getApplicationContext();
        int notificationId = 1;
     //------------ for test ------------
        Log.d("LocalService", "onStartCommand Received start id" + startId + ": " + intent);
        Toast toast = makeText(this, "Start service", LENGTH_LONG);
        toast.show();

     // -------------- begin notification ---------------
        Intent notificationIntent = new Intent(this, MainActivity.class);
        notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);

        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
        String channelId = "default";
        NotificationChannel channel = new NotificationChannel(
                channelId, context.getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW);
        if (notificationManager != null) {
            notificationManager.createNotificationChannel(channel);
        }

        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
                .setSmallIcon(R.drawable.ic_launcher_foreground)
                .setContentTitle("サービス")
                .setContentText("サービスが起動中です。終了するには通知をタップしてアプリから終了してください。")
                .setPriority(NotificationCompat.PRIORITY_DEFAULT)
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
                .setContentIntent(pendingIntent)
                .setAutoCancel(true);

        Notification notification = builder.build();
        notification.flags |= Notification.FLAG_ONGOING_EVENT;

        startForeground(notificationId, notification);
// -------------- end notification ---------------

        handler.post(runnable);

        return START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onDestroy() {
        Log.d("LocalService", "onDestroy");
        Toast toast = makeText(this, "Stop service", LENGTH_LONG);
        toast.show();
        handler.removeCallbacks(runnable);

    }
}
```
作ったServiceが動かせるように、AndroidManifest.xmlに以下を追記します。

```AndroidManifest.xml
 <service android:name=".CachePositionService" />
```
Android 9以降に対応するのには、[こちら](https://qiita.com/naoi/items/03e76d10948fe0d45597#%E3%82%BF%E3%83%BC%E3%82%B2%E3%83%83%E3%83%88%E3%82%9228%E4%BB%A5%E4%B8%8A%E3%81%AB%E3%81%99%E3%82%8B%E5%A0%B4%E5%90%88pie%E4%BB%A5%E9%99%8D)にあるように`AndroidManifest.xml`に、以下の権限

```AndroidManifest.xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
```
を追加しなければなりません。

### 一定時間毎に行う処理

まず、一定時間(ここでは1秒)ごとの処理を記述するのには`Handler.postDelayed()`メソッドを使います。このHandlerは他のThreadとの通信をするもの。Runnableクラスのインスタンスを投げることが出来ます。Runnableクラスのインスタンスはお決まりがあるようです(Android studioでは、Runnableのインスタンスを書こうとすると自動的に`Runnable.run()`をOverrideする形で記述するように出てきた)。
Serviceが実行中に行われる`onStartCommand`内で

```java
handler.post(runnable)
```
とし、Serviceが終了される時に行われる`onDestroy`内で

```java
handler.removeCallbacks(runnable)
```
とすることで、handlerの処理を制御出来ます。

### 通知に関して

通知バーに出てくる通知をタップすると、MainActivityが呼ばれるようにしたいです。この際にもIntentを渡します。が、この時は通知を押されるという処理が行われるまでIntentを発行したくないので、そんな時には`PendingIntent`というものを使います。
そして、`NotificationManagerCompat`[^1]にてこのServiceにおけるContextについて通知を出すよと書いて、Builderで具体的な通知内容の雛形を書き、その雛形を元にBuildします。
ここでのポイントですが、BuildしたNotificationのインスタンスに、バックグラウンドで動いているときには通知を消せないように

```java
notification.flags |= Notification.FLAG_ONGOING_EVENT;
```
を追加します。
また、`notificationId`に0を入れると通知が表示されないので、別の数字にします。

[^1]: 公式のReferenceを見ると、`NotificationManager`,`Notification`を用いるより`NotificationManagerCompat`,`NotificationCompat`を使用した方が複数バージョンの互換性があるらしいです。


### 作ったServiceが動いているかをActivityで確認する

今、自分が作ったServiceが動いているのかをActivityで確認するのに最初は, `MainActivity.java`にて

```java
    boolean isActive() {
        boolean active = false;
        ActivityManager activityManager = (ActivityManager)this.getSystemService(ACTIVITY_SERVICE);
        for (ActivityManager.RunningServiceInfo serviceInfo : activityManager.getRunningServices(Integer.MAX_VALUE)) {
            if (TestService.class.getName().equals(serviceInfo.service.getClassName())) active = true;
        }
    return active;
```
のように全ての起動中のServiceを舐め回して、Stringで一致する時に`true`を返すようにしていましたが、これはAndroid 8 (API > 26)からは`getRunningServices`メソッドが非推奨となったようなので、代わりにServiceの方でstaticなbool型変数を用意しておいて、`onDestroy()`が呼ばれるまで`true`にしておくという形にしました。

# 結果

まず、出来た画面がこちら
![Screenshot_1574933592.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/8374e076-ae44-9ff7-7c36-ea0f62151026.png)
ToggleButtonをタップすると"Service start"というToast表示が出力されると共に通知バーに通知が現れます。Logcatを確認すると`TestService.onStartCommand`が呼ばれているのが分かります。
![Screenshot_1574933596.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/038664e4-eff1-3ec4-eba3-f1d11f8048ba.png)
通知もこのように出てきます(目隠ししているのはアプリの名前です)
![InkedScreenshot_1574933606_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/ee152b50-3b2d-3ae0-b5de-b5a54d1eca5a.jpeg)
Serviceが生きている時に、Activityを破棄(アプリを終了)されても、Serviceは生きていることが確認出来ます。通知バーの通知は消えませんし、Logcatの方ではServiceが生き続けていて1秒毎にログが出力されている筈です。
最後に、通知をタップしてActivityを再び起動させ、ToggleButtonを見ると、On状態のままになっています。このToggleを押すと"stop service"というToastと共に通知が消えます。`TestService.onDestroy`も無事にCallされていることがLogを見ても分かります。
![Screenshot_1574933620.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/550e2fd6-c912-360f-69b0-71a4148ce307.png)

これで狙った挙動になりました!

# 参考
一通りのチュートリアルは公式のこちら
https://developer.android.com/training/basics/firstapp?hl=ja
レイアウトの作り方(公式)
https://developer.android.com/training/basics/firstapp/building-ui?hl=ja
トグルボタンについて
https://developer.android.com/guide/topics/ui/controls/togglebutton?hl=ja
Serviceの解説(公式)
https://developer.android.com/guide/components/services
サービス中の通知について(公式)
https://developer.android.com/guide/components/services#Foreground
サービスの使い方
https://akira-watson.com/android/service.html
定期的に処理を行う方法について
https://qiita.com/aftercider/items/81edf35993c2df3de353