AndroidXでステータスバーから直接入力できるメモアプリを作る。

Androidでステータスバーに直接入力・閲覧できるメモアプリを作る。 – return $lock;
これの続きです。Android 7から10に環境が変わったのでメモアプリを作り直しました。android.supportはもう古いのでAndroidXを使うことにします。
完成したアプリはこんな感じです。画像をクリックするとGIFアニメが再生されます。

ステータスバーから入力できるメモアプリ
ステータスバーから入力できるメモアプリ

コードが多いので残りは続きを読むに書きます。

使用したライブラリ

  • Android API Level 29
  • androidx.appcompat 1.1.0
  • OkHttp 4.4.0

実装

HTTP通信する場合、メインスレッド以外から通信する必要があるため、前回と同様に以下の構成で実装しました。正しいかどうかは相変わらずわからない。俺たちは雰囲気でコードを書いている。

  • MainActivity
    • Foreground Serviceの起動用にのみ使用するアクティビティ
  • NotificationService(Foreground Serviceとして起動)
    • ステータスバーに通知を常駐させるためのサービス
  • HTTPService(IntentService)
    • NotificationServiceから呼び出されて、HTTP通信するためのサービス
    • 通信完了後にNotificationServiceを再度startする

サービス同士でメッセージをやり取りする際に、IntentにputExtraでStringを詰めています。
前回同様Androidは自信がないので、自分の期待する動作になることと、極端にメモリ食いつぶさないことを目標にして実装します。

実装したコード

build.gradle

// 色々略
dependencies {
    // 色々略
    implementation 'com.squareup.okhttp3:okhttp:4.4.0'
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
}

MainActivity

起動初回のみstartServiceするためだけのアクティビティです。それ以外は特になにもしていないです。

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;

import your.package.service.NotificationService;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Intent i = new Intent(this, NotificationService.class);
        i.putExtra("KEY_EXTRA_STRING", "入力してください");
        startService(i);
    }
}

NotificationService

前回との差分として、通知チャンネルを作成しています。
サービス開始のたびにチャンネルを作成するコードになっていますが、公式チュートリアルに以下の文言があるので多分大丈夫だと思います。心配な方はご自身でデバッグしてください。

既存の通知チャネルをその値を使って新たに作成しようとしても、操作は実行されません。そのためアプリを開始するときに、このコードを実行しても問題ありません。

なお、ダイレクト返信アクションの実装ですが、この記事を書いた時点では公式のチュートリアルに誤りがあり、写経しても動きません

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput;

import java.util.Objects;

public class NotificationService extends Service {
    private static final String TAG = "NotificationService";
    private static final int NOTIFICATION_ID = 1;
    private static final String CHANNEL_ID = "CHANNEL_ID"; // 通知チャンネルのIDにする任意の文字列
    private static final String CHANNEL_NAME = "チャンネルの名前"; // 通知チャンネル名

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

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {

        Log.d(TAG, "onStartCommand");

        // チャンネルの作成
        // Android 7以下をサポートする場合は、APIレベル26以上でのみ作成するようにSDK_INTで判定する必要がある。
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
        NotificationManager nm = getSystemService(NotificationManager.class);
        Objects.requireNonNull(nm).createNotificationChannel(channel);

        final String replyLabel = "ここに入力してください。";
        RemoteInput remoteInput = new RemoteInput.Builder("KEY_TEXT_REPLY")
                .setLabel(replyLabel)
                .build();

        Intent i = new Intent(this, HTTPService.class);
        PendingIntent replyPendingIntent = PendingIntent.getService(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);

        NotificationCompat.Action action = new NotificationCompat.Action.Builder(android.R.drawable.ic_menu_edit,
                replyLabel, replyPendingIntent).addRemoteInput(remoteInput).build();

        Notification newMessageNotification =
                // チュートリアルではNotification.BuilderだがactionがNotificationCompatなのでこちらもcompatにする必要がある
                new NotificationCompat.Builder(this, CHANNEL_ID)
                        .setSmallIcon(android.R.drawable.ic_menu_edit)
                        .setContentTitle("通知のタイトル")
                        .setContentText(intent.getStringExtra("KEY_EXTRA_STRING"))
                        .setPriority(NotificationCompat.PRIORITY_HIGH)
                        .addAction(action).build();

        startForeground(NOTIFICATION_ID, newMessageNotification); // Foreground Service開始

        return START_STICKY;
    }
}

HTTPService

前回同様、OkHttpでAPIと通信したあと、APIから返ってきたレスポンスをputExtraでIntentに詰めてForeground Serviceに返すサービスです。

import android.app.IntentService;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;

import androidx.core.app.RemoteInput;

import java.io.IOException;
import java.util.Objects;

import okhttp3.FormBody;
import okhttp3.Request;
import okhttp3.Response;

import your.package.OkHttp3Singleton;

public class HTTPService extends IntentService {
    private static final String TAG = "HTTPService";
    private static final String API_URL = "https://example.com/your_api";
    public HTTPService() {
        super(TAG);
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        Log.d(TAG, "onHandleIntent");

        CharSequence messageText = getMessageText(intent);
        String extraMessage = "";

        if (messageText != null) {

            final String message = messageText.toString();
            Log.d(TAG, message);

            final FormBody.Builder formBodyBuilder = new FormBody.Builder();
            formBodyBuilder.add("message", message);

            final Request request = new Request.Builder()
                    .url(API_URL)
                    .header("YOUR_API_KEY", "VALUE")
                    .post(formBodyBuilder.build())
                    .build();
            try (Response response = OkHttp3Singleton.INSTANCE.getOkHttpClient().newCall(request).execute()) {
                if (response.isSuccessful()) {
                    extraMessage = Objects.requireNonNull(response.body()).string(); // レスポンスをIntentに詰めて別Serviceに渡す
                } else {
                    extraMessage = "エラー:" + response.code() + " が発生しました。";
                }
            } catch (IOException e) {
                extraMessage = "送信失敗。";
            }

        } else {
            Log.d(TAG, "No message.");
        }
        Intent i = new Intent(this, NotificationService.class);
        i.putExtra("KEY_EXTRA_STRING", extraMessage);
        startService(i);
    }

    private CharSequence getMessageText(Intent intent) {
        Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
        if (remoteInput != null) {
            return remoteInput.getCharSequence("KEY_TEXT_REPLY");
        }
        return null;
    }
}

manifest.xml

Permissionに以下が必要です。

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

感想

1日動かしましたがメモリ使用量が5MBなので問題ないかなと思います。設定でサイレント通知に持っていけば通常の通知の邪魔にならないので便利ですね。Android 10めちゃくちゃ使いやすくて嬉しいです。