【Java】Caller パッケージ名 needs to hold android.permission.SCHEDULE_EXACT_ALARM to set exact alarms.

タイトルのエラーに出くわしてしまいました。

AndroidManifest.xml内 で以下のように宣言するだけで良いと思っていたので出くわす理由がわかりませんでした…

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

公式ドキュメント

AlarmManager を使って正確な時間にアラームを設定してサービスを動かしたかったのでこれで、AlarmManager の setExact() などのexactを含むメソッドを利用できると思っていました。

てか、実際にそう出来る端末がありました。
ただ、他にandroid でタイトルのエラーを起こす機種がいました。

何はともあれ対策です。

どうやら AlarmManager には 正確にスケジューリング出来るか確認できるメソッド canScheduleExactAlarms() があるようです。

android12以上だと使えます。

AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
    alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent);
} else {
    alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent);
}

canScheduleExactAlarms() メソッドのドキュメントを見ると

SCHEDULE_EXACT_ALARM権限を持つ、またはデバイスの省電力除外リストに入っているとtrueを返してくれるみたいです。

つまりエラーが起きたということはどちらともダメだったということです。
上述した実際に出来た端末は元々デバイスの省電力除外リストに入れていたからっぽいですね…

なんにせよユーザー側で設定を変えれるので上記のような分岐は行う必要があります。

Exactを使わせたい場合は、Intentで「Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM 」を指定して設定してもらうか省電力モードから外す必要がありそうです。

12への変更点に書いておいて欲しかったです…

SQSのvisibleメッセージの数だけECSタスクを起動する方法

ご無沙汰しております。オンラインコンサルタントの直井です。今回はECS周りについてご紹介いたします。

1. 動機

ECSのタスクをSQSのメッセージ数に応じてオートスケールしたいな…と思ったことがことの発端です。

要は、バッチ処理をメッセージに応じてスケーリングするといった内容です。今回の内容ではクラスター内のサービスだと、メッセージ数に応じてタスクをスケールアウトすることは可能ですが、スケールインの設定が難しいため、このような実装方法になりました。

2. 動作環境

Python:3.9

ECSのタスクは、処理が終了すればコンテナが終了してスケールインの操作が不要な状態

3. Lambdaサンプルコード

import boto3
import os
import time
import datetime

ecs_client = boto3.client("ecs")
cw_client = boto3.client('cloudwatch')

ECS_CLUSTER = ""
TASK_DEFINITION = ""
SUBNET_ID_1 = ""
SUBNET_ID_2 = ""
QUEUE_NAME = ""
def lambda_handler(event, context):
    
    response = cw_client.get_metric_statistics(
               Namespace = 'AWS/SQS',
               MetricName = 'ApproximateNumberOfMessagesVisible',
               Dimensions = [
                            {
                                'Name': 'QueueName',
                                'Value': QUEUE_NAME
                            },
                        ],
                StartTime=datetime.datetime.now() - datetime.timedelta(seconds=60),
                EndTime=datetime.datetime.now(),
               Period = 60,
               Statistics = ['Maximum']
               )
    metric = int(response['Datapoints'][0]['Maximum'])
    if metric > 0 :
        print("Create!!")
        for num in range(metric):
            ecs_client.run_task(
                cluster=ECS_CLUSTER,
                launchType="FARGATE",
                networkConfiguration={
                    "awsvpcConfiguration": {
                        "subnets": [SUBNET_ID_1,SUBNET_ID_2],
                        "assignPublicIp": "ENABLED",
                    }
                },
                taskDefinition=TASK_DEFINITION,
            )
            time.sleep(10)
    else:
        print("None")

ざっくりとコード内の説明

発火した過去1分でSQSで利用できるメッセージ数(visibleCount)を取得して、その数に応じて繰り返し処理でECSのタスクを起動させるようにしています。

visibleカウントは処理中ではないメッセージ数に応じて値が変化します。

4. 発火イベントを仕込む

右上のトリガーの追加を選択

バーに「event」と入力して「EventBridgeを選択」

「新規ルールの作成」を選択肢、「ルール名」と「スケジュール式」を入力して「追加」ボタンをクリック

※1分や1時間の場合は、1 minute, 1 hour だが、2以上であれば 2minutes, 2 hoursになるので注意

5. 最後に

サービスをうまく使ってどうにかできないか考えましたが、今回構成したシステム群が疎結合に作れたので、柔軟性があり助かりました。

ECSではちょいちょいコンソール画面に対応していないもしくは、新UIに対応していないことが多いので戸惑うことが多かったです。

SQSの メッセージ最大サイズ「256KB」にキレてます(>_<)

お久しぶりです。オンラインコンサルタントの直井です(^^♪
皆様いかがお過ごしでしょうか?最近は暑さも和らぎ、比較的過ごしやすい日々が続いております。気温の変化は体調にも表れやすいので、どうぞお気をつけてお過ごしください。

という堅苦しいはいりは置いておいて、今回はタイトルの通りSQSの最大メッセージサイズが256KBということにキレています。このサイズ、微妙に足りなくないですか…?そういうのには向いてないとか、そもそもそんな大きい内容をキューに入れておくな!とか言われるかもしれませんが、入れたいんですわ!入れないと進めないんですわ!なんて状況は多々あると思います。

1. SQSを利用する構成

そもそも、どんな感じでSQSを利用するか?について説明いたします。今回弊社ではルート計算プログラムをサーバレス環境に移行すべく、API Gateway,Lambda, SQSを利用を検討しました。以下の図の通りです。

これまで動いていた環境では、高性能なサーバで常にリクエストを待ち受けて、リクエストがあったら計算する形だったので、待ち受け時間が多いと不要なリソースとして問題がありました。これを解決するため、イベント駆動型に近いサーバレス環境に移行する計画が実行されました。

2. リクエストが肥大化していた

プログラムで計算するための情報は、計算して欲しい地点の位置情報(緯度経度)情報くらいなのでPOSTデータはそこまで大きくないだろう(^^♪ラッキー!標準機能でごり押し移行ができると考えていました。

ですが、実際にリクエストされる内容を確認してみると….その地点の位置情報だけでなく、備考情報やid等計算に必要ない内容もPOSTされており、戦慄しました。また、その内容をそっくりそのまま返す必要があります。

このため、リクエストが、2MBや5MBなどを超えることが多々あります。このことから、SQS標準のメッセージ容量では足りないことが明らかとなりました。

3. メッセージを拡張

このような困った状況でも、AWSは最適なソリューションを用意してくれていました。S3を使えばメッセージの容量を最大2GBまで拡張できるよ!とのことです。これは心強い!2GBもあれば何でもできます。なんでも。というかS3を使った時点で何でもできるような気がしてきました。

https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-s3-messages.html

4. リクエストを受け付けてSQSに登録する

できそうなことがわかったので、とりあえず作っていきます。作る部分は、APIGatewayから受け取るLambda関数です。今回の計算プログラムがScalaで掛かれていたので、それそそっくりそのまま受け継ぎます。Lambdaのランタイムは、java on amazon linux2 を利用します。

受付部分はさっきの公式ドキュメントをそのまま利用します。

    import software.amazon.awssdk.regions.Region
    import software.amazon.awssdk.services.sqs.SqsClient
    import software.amazon.awssdk.services.sqs.model._
    import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
    import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
    import com.amazon.sqs.javamessaging.AmazonSQSExtendedClient
    import com.amazon.sqs.javamessaging.ExtendedClientConfiguration
    import software.amazon.awssdk.services.s3.S3Client
    import software.amazon.awssdk.services.s3.S3ClientBuilder

    def sentSqsMessage(request_json: String): SendMessageResponse = {

        val S3_BUCKET_NAME: String = "sqs_message_bucket_name"
        val AWS_ACCESS_KEY: String = ""
        val AWS_SECRET_KEY: String = ""
        val QUEUE_URL: String = "sqs_queue_url"

        val awsCredentials = StaticCredentialsProvider.create(AwsBasicCredentials.create(AWS_ACCESS_KEY, AWS_SECRET_KEY))
        val s3Client: S3Client = S3Client.builder().region(Region.AP_NORTHEAST_1).credentialsProvider(awsCredentials).httpClient(ApacheHttpClient.create()).build()
        val qsClient: SqsClient = SqsClient.builder().region(Region.AP_NORTHEAST_1).credentialsProvider(awsCredentials).httpClient(ApacheHttpClient.create()).build()
        val extendedClientConfig = new ExtendedClientConfiguration().withPayloadSupportEnabled(s3Client, S3_BUCKET_NAME)
        val sqsExtendClient: AmazonSQSExtendedClient = new AmazonSQSExtendedClient(sqsClient, extendedClientConfig)
        val sendMessageRequest: SendMessageRequest = SendMessageRequest.builder().queueUrl(QUEUE_URL).messageBody(request_json).build()
        val sendResult: SendMessageResponse = sqsExtendClient.sendMessage(sendMessageRequest)

        return sendResult
    }

ざっくりと処理の流れを記載すると、AWS クレデンシャルをを生成し、S3とSQSのクライアントを作成します。その後に、SQSをS3を利用して拡張するためのクライアントを作成しリクエストを投げる流れになります。

5. 最後に

あまり日本語で書かれている記事がなく、試し試しで動かしたことを昨日のことのように思い出します….

【Java】Fatal Error Context.startForegroundService() did not then call Service.startForeground()

凄く大事なことなので、載せておきます。
Android8.0対応をした方には凄く悩まされた記事だと思いますが…

公式ドキュメント

中盤辺りに以下のように書かれています。

Android 8.0 では、フォアグラウンドで新しいサービスを開始する startForegroundService() メソッドが新たに導入されています。 システムによってサービスが作成されると、アプリは、サービスの startForeground() メソッドを 5 秒以内に呼び出して、その新しいサービスの通知をユーザーに表示します。 アプリが startForeground() を制限時間内に呼び出さない場合、システムによってサービスが停止され、アプリが ANR になります。

要は、startForegroundService() を呼んで、サービスを起動したときに startForeground() メソッドを 5 秒以内に呼び出さないとクラッシュしてしまいます。

intent = new Intent(context, SampleService.class);
context.startForegroundService(intent);

SampleService.java

@Override
public int onStartCommand(Intent intent, int flags, int startid) {
    super.onStartCommand(intent, flags, startid);
    boolean hasPermission = false;

    // hasPermissionがない場合、クラッシュする
    if (hasPermission) {
        NotificationCompat.Builder builder = buildNotification(); // 自作の通知作成メソッド
        startForeground(NOTIFICATION_ID, builder.build()); // NOTIFICATION_IDは任意の数字
    }
}

上記の例だとクラッシュしてしまいます。
startForeground() が必ず呼ばれる仕組みになっているか確認が必要です。
serviceの中身が複雑になってくればくるほど、確認がしづらいのでシンプルな設計を心掛けたいですね。
IDEがこの辺り検知してくれないかな…

【Java】Activity Recognition Transition API をForegroundServiceを用いて利用する

ユーザーが、「徒歩」、「自動車の運転中」、「自転車の運転中」、「ランニング中」、「静止中」などをAndroidで検知したい場合は、Activity Recognition Transition API を利用するかと思います。

こちらが、公式ドキュメントです。

公式ではブロードキャストレシーバーを利用して、APIからのコールバックを受け取っています(2022/08/20時点)。

今回は、ForegroundServiceを利用して簡易的なサンプルを作ってみたいと思います。
Android SDK API レベル は、31(Android12)を想定しています。

まずは、AndroidManifest.xmlとbuild.gradle内で権限の指定の追加や、ライブラリのインポートを行います。

AndroidManifest.xml

<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="com.google.android.gms.permission.ACTIVITY_RECOGNITION" />

build.gradle (dependenciesに追加)

implementation 'com.google.android.gms:play-services-location:20.0.0'

行動認知のタスクの登録やキャンセルを行うActivityRecognitionManagerクラスを作成します。
このように行う必要はないのですが、 個人的にこういったマネージャークラスを 作成するのが好きです。

ActivityRecognitionManager.java

public class ActivityRecognitionManager {

    private final ActivityRecognitionCallback mCallback;

  /**
     *  タスクが登録された、あるいはキャンセルされたときに使われるコールバック
     */
    public ActivityRecognitionManager(ActivityRecognitionCallback callback) {
        mCallback = callback;
    }

    private List<ActivityTransition> getActivityTransitions() {

        List<ActivityTransition> transitions = new ArrayList<>();

        ArrayList<Integer> activities = new ArrayList<>(Arrays.asList(
                DetectedActivity.STILL,
                DetectedActivity.WALKING,
                DetectedActivity.RUNNING,
                DetectedActivity.ON_BICYCLE,
                DetectedActivity.IN_VEHICLE));

        for (int activity : activities) {
            transitions.add(
                    new ActivityTransition.Builder()
                            .setActivityType(activity)
                            .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER)
                            .build());

            transitions.add(
                    new ActivityTransition.Builder()
                            .setActivityType(activity)
                            .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_EXIT)
                            .build());
        }

        return transitions;
    }

    public void startTask(Context context, PendingIntent pendingIntent) {

        if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
            return;
        }
        
        List<ActivityTransition> transitions = getActivityTransitions();
        ActivityTransitionRequest request = new ActivityTransitionRequest(transitions);
        Task<Void> task = ActivityRecognition.getClient(context).requestActivityTransitionUpdates(request, pendingIntent);

        task.addOnSuccessListener(
                result -> {
                    // Handle success
                    mCallback.onTaskRegistered();
                }
        );

        task.addOnFailureListener(
                e -> {
                    // Handle error
                    Log.e("MY_COMPONENT", e.getMessage());
                }
        );
    }

    public void cancelTask(Context context, PendingIntent pendingIntent) {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && ActivityCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
            return;
        }

        Task<Void> task = ActivityRecognition.getClient(context).removeActivityTransitionUpdates(pendingIntent);

        task.addOnSuccessListener(
                result -> mCallback.onTaskCancelled()
        );

        task.addOnFailureListener(
                e -> {
                }
        );
    }

}

コールバックインターフェースです。
タスクの登録などは、Activityで行います。

ActivityRecognitionCallback.java

/**
 * Class ActivityRecognitionCallback
 */

public interface ActivityRecognitionCallback {
    void onTaskRegistered();
    void onTaskCancelled();
}

コールバックを受け取るMainActivityです。
MainActivity.java

public class MainActivity extends AppCompatActivity implements ActivityRecognitionCallback {

    public static final String TAG = "MainActivity";

    private TextView mActivityTypeTv;
    private TextView mTransitionTypeTv;
    private TextView mStatusTv;
    private Button mStartButton;
    private Button mStopButton;
    private Context mContext;
    private ActivityRecognitionManager mActivityRecognitionManager;
    private PendingIntent mTransitionPendingIntent;


    /**
     * 行動認知を受け取ったら、ActivityTransitionServiceよりブロードキャストされてUIを更新する
     */
    BroadcastReceiver uiReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String activityTypeToString = intent.getStringExtra("activityTypeToString");
            String transitionTypeToString = intent.getStringExtra("transitionTypeToString");
            String activityTypeText = getString(R.string.detective_activity_type) + " : " + activityTypeToString; // 歩いているなど
            String transitionTypeText = getString(R.string.start_or_stop) + " : " + transitionTypeToString; // 開始か終了か
            mActivityTypeTv.setText(activityTypeText);
            mTransitionTypeTv.setText(transitionTypeText);
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mContext = this;

        // UI ここから
        setTitle(R.string.main_title);
        setContentView(R.layout.activity_main);
        setLayoutProperty();
        setTextView();
        setOnListener();
        // ここまで

        mActivityRecognitionManager = new ActivityRecognitionManager(this);
        Intent transitionServiceIntent = new Intent(mContext, ActivityTransitionService.class);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            mTransitionPendingIntent = PendingIntent.getForegroundService(mContext, 0, transitionServiceIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
        } else {
            mTransitionPendingIntent = PendingIntent.getForegroundService(mContext, 0, transitionServiceIntent, PendingIntent.FLAG_UPDATE_CURRENT);
        }

    }

    private void setOnListener() {
        mStartButton.setOnClickListener(view -> {
            Log.d(TAG, "tap start-button");

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(mContext, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
                requestPermission();
            } else {
                registerUiReceiver();
                mActivityRecognitionManager.startTask(mContext, mTransitionPendingIntent);
            }

        });

        mStopButton.setOnClickListener(view -> {
            Log.d(TAG, "tap stop-button");
            if (mTransitionPendingIntent != null) {
                mActivityRecognitionManager.cancelTask(mContext, mTransitionPendingIntent);
            }
        });

    }

    private void setTextView() {
        SharedPreferences sharedPreferences = mContext.getSharedPreferences("activity_transition_sample", Context.MODE_PRIVATE);
        String activityType = sharedPreferences.getString("activity_type", "");
        String transitionType = sharedPreferences.getString("transition_type", "");

        if (!activityType.equals("")) {
            String activityTypeText = getString(R.string.detective_activity_type) + " : " + activityType;
            mActivityTypeTv.setText(activityTypeText);
        }

        if (!transitionType.equals("")) {
            String transitionTypeText = getString(R.string.start_or_stop) + " : " + transitionType;
            mTransitionTypeTv.setText(transitionTypeText);
        }
    }

    private void setLayoutProperty() {
        mStatusTv = findViewById(R.id.statusText);
        mStartButton = findViewById(R.id.start);
        mStopButton = findViewById(R.id.stop);
        mActivityTypeTv = findViewById(R.id.activityType);
        mTransitionTypeTv = findViewById(R.id.transitionType);
    }

    @RequiresApi(api = Build.VERSION_CODES.Q)
    private void requestPermission() {
        String[] permissions = new String[]{Manifest.permission.ACTIVITY_RECOGNITION};
        ActivityCompat.requestPermissions(this, permissions, 1);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            registerUiReceiver();
            mActivityRecognitionManager.startTask(mContext, mTransitionPendingIntent);
        }
    }

    protected void onDestroy() {

        if (uiReceiver != null) {
            unregisterReceiver(uiReceiver);
        }

        super.onDestroy();
    }

    /**
     * タスクを登録したらサービスを起動
     */
    private void startActivityTransitionService() {
        Intent intent = new Intent(mContext, ActivityTransitionService.class);
        mContext.startForegroundService(intent);
    }

    /**
     * タスクを削除したらサービスを停止
     */
    private void cancelActivityTransitionService() {
        Intent intent = new Intent(mContext, ActivityTransitionService.class);
        mContext.stopService(intent);
    }

    /**
     *  タスクが登録されたら呼ばれるコールバックメソッド
     */
    @Override
    public void onTaskRegistered() {
        mStatusTv.setText(R.string.detective_activity_start);
        startActivityTransitionService();
    }

    /**
     *  タスクがキャンセルされたら呼ばれるコールバックメソッド
     */
    @Override
    public void onTaskCancelled() {
        mStatusTv.setText(R.string.detective_activity_stop);
        cancelActivityTransitionService();
    }

    /**
     * 行動認知を受け取った際に、画面更新を促すために、ActivityTransitionServiceよりブロードキャストするレシーバーを登録
     */
    private void registerUiReceiver() {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(MyConstants.UPDATE_MAIN_UI);
        registerReceiver(uiReceiver, intentFilter);
    }
}

行動認知の登録を受け取ったらサービスを起動させます。

public class ActivityTransitionService extends Service {

    public static final String TAG = "ActivityTransitionService";
    private Context mContext;

    @Override
    public IBinder onBind(Intent intent) {
        throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public void onCreate() {
        super.onCreate();
        mContext = this;
        NotificationCompat.Builder builder = buildNotification();
        startForeground(334, builder.build());
    }


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

        Log.d(TAG, "result:"+ ActivityTransitionResult.hasResult(intent));

        if (ActivityTransitionResult.hasResult(intent)) {
            ActivityTransitionResult result = ActivityTransitionResult.extractResult(intent);
            if (result != null) {
                for (ActivityTransitionEvent event : result.getTransitionEvents()) {

                    String activityTypeToString = getActivityTypeToString(event.getActivityType());
                    String transitionTypeToString = getTransitionTypeToString(event.getTransitionType());

                    Log.d(TAG, activityTypeToString);
                    Log.d(TAG, transitionTypeToString);

                    saveActivityTransition(activityTypeToString, transitionTypeToString);
                    sendToMainActivity(activityTypeToString, transitionTypeToString);

                }
            }
        }

        return START_STICKY;
    }

    @Override
    public void onDestroy() {
        stopSelf();
    }

    /**
     * MainActivityのUIへ画面更新を促すためにブロードキャスト
     * @param activityTypeToString 
     * @param transitionTypeToString
     */
    private void sendToMainActivity(String activityTypeToString , String transitionTypeToString) {
        Intent broadcastIntent = new Intent();
        broadcastIntent.putExtra("activityTypeToString", activityTypeToString);
        broadcastIntent.putExtra("transitionTypeToString", transitionTypeToString);
        broadcastIntent.setAction(MyConstants.UPDATE_MAIN_UI);
        mContext.sendBroadcast(broadcastIntent);
    }

    /**
     * 受け取ったactivityTypeとtransitionTypeを保存しておく
     * @param activityTypeToString
     * @param transitionTypeToString
     */
    private void saveActivityTransition(String activityTypeToString, String transitionTypeToString) {
        SharedPreferences sharedPreferences = mContext.getSharedPreferences("activity_transition_sample", Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putString("activity_type", activityTypeToString);
        editor.putString("transition_type", transitionTypeToString);
        editor.apply();

        DataBaseHelper helper = new DataBaseHelper(mContext);
        helper.saveActivityRecognition(activityTypeToString, transitionTypeToString);
    }

    /**
     * アクティビティタイプを「走っている」などの日本語に変換
     * @param activityType
     * @return String
     */
    private String getActivityTypeToString(int activityType) {

        String activityTypeToString;

        switch (activityType) {
            case DetectedActivity.IN_VEHICLE:
                activityTypeToString = mContext.getString(R.string.in_vehicle);
                break;
            case DetectedActivity.ON_BICYCLE:
                activityTypeToString = mContext.getString(R.string.on_bicycle);
                break;
            case DetectedActivity.RUNNING:
                activityTypeToString = mContext.getString(R.string.running);
                break;
            case DetectedActivity.STILL:
                activityTypeToString = mContext.getString(R.string.still);
                break;
            case DetectedActivity.WALKING:
                activityTypeToString = mContext.getString(R.string.walking);
                break;
            default:
                activityTypeToString = mContext.getString(R.string.unknown);
                break;
        }

        return activityTypeToString;
    }

    /**
     * 「開始」か「終了」という日本語に変換
     * @param transitionType
     * @return String
     */
    private String getTransitionTypeToString(int transitionType) {

        if (transitionType == ActivityTransition.ACTIVITY_TRANSITION_ENTER) {
            return mContext.getString(R.string.enter);
        } else {
            return mContext.getString(R.string.exit);
        }

    }

    /**
     * 通知を作成
     * @return NotificationCompat.Builder
     */
    public NotificationCompat.Builder buildNotification() {

        NotificationCompat.Builder builder;

        String title = mContext.getString(R.string.app_name);
        NotificationChannel channel;

        String message = mContext.getString(R.string.detective_activity_start);

        channel = new NotificationChannel("ActivityRecognition", mContext.getString(R.string.detective_activity_notification), NotificationManager.IMPORTANCE_DEFAULT);
        channel.setDescription(mContext.getString(R.string.detective_activity_notification));

        channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
        channel.enableVibration(true);
        channel.enableLights(true);
        channel.setShowBadge(false);

        NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);

        notificationManager.createNotificationChannel(channel);

        int icon = R.drawable.ic_launcher_foreground;
        String color = "#31da61";

        builder = new NotificationCompat.Builder(mContext, "ActivityRecognition")
                .setContentTitle(title)
                .setSmallIcon(icon)
                .setOngoing(true)
                .setStyle(new NotificationCompat.BigTextStyle().bigText(message))
                .setColor(Color.parseColor(color));

        Intent MainActivityIntent = new Intent(mContext, MainActivity.class);

        PendingIntent pendingIntent;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
            pendingIntent = PendingIntent.getActivity(mContext, 0, MainActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
        } else {
            pendingIntent = PendingIntent.getActivity(mContext, 0, MainActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT  | PendingIntent.FLAG_IMMUTABLE);
        }
        builder.setContentIntent(pendingIntent);

        return builder;
    }
}

いくつかの端末で試してみましたが、行動認知は、端末によって精度にムラがあるように感じました。
これから精度が上がってくることを期待したいです。