方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter × Go × gRPC】アプリ強制アップデート機能の実装(画面ロック)

アプリ強制アップデート
この記事は約23分で読めます。

はじめに

前回、こちらの記事でファームウェアのアップデート通知機能を実装した。今回は、アプリの強制アップデート機能を実装する。

実装方法

「Flutter Firebase アプリアップデート」で検索すると、RemoteConfigを使った方法と、FirebaseDetabaseを使った方法の大きく2種類がヒットした。

ただし、今回はすでに作ったファームウェアの通知と同じ仕組みで実装できそうなので、自前で実装。下記方針で進める。

サーバー(Go)

  • バージョン比較:アプリ上で現在のアプリのバージョンを取得できるので、現在の必須バージョンと比較
  • 新しいバージョンの通知: ファームウェアのgRPCメソッドであるListenAppUpdates RPC メソッドを拡張して共通化する。データベースのバージョン情報と比較し、新しいファームウェアもしくはアプリのバージョンが利用可能であれば Flutter アプリに通知。

アプリ(Flutter)

  • 現在のアプリのバージョンをサーバーに通知protoファイルの、StreamRequestメッセージにcurrent_app_versionを追加。現在のアプリのバージョンはpackage_info_plusパッケージを利用して取得する。
  • 新しいバージョンの通知を受信:アップデートの通知をリッスンする。
  • 強制アップデート:新しいアップデート通知がある場合、ホーム画面をロックしてダイアログで新しいアップデートバージョンを通知し、「今すぐ更新する」ボタンを表示する。ユーザーはアップデートをキャンセルできないようにする。

gRPCインターフェースを修正

notification.protoファイルを下記のように修正

  • StreamRequest メッセージに current_app_version フィールドを追加し、クライアントがサーバーに現在のアプリのバージョンを通知できるようにした。
  • 新しい FirmwareUpdateAppUpdate メッセージを追加し、ファームウェアとアプリの更新情報を個別に含めることができるようにした。
  • UpdateVersionResponse メッセージを追加し、ファームウェアとアプリの両方の更新情報を1つのレスポンスで返すことができるようにした。
  • NotificationService サービスに ListenUpdates メソッドを追加し、ファームウェアとアプリの更新情報をストリームとして返すことができるようにした。

この変更により、アプリの現在のバージョンをサーバーに渡し、サーバーがアプリとファームウェアの両方の更新情報をクライアントに通知することが可能になる。

このnotification.protoの変更は、アプリ(Flutter)とサーバー(Go)の両方で変更を適用する。

Go
// protos/notification.proto
syntax = "proto3";

package protos;
option go_package = "github.com/EarEEG-dev/clocky_be/pb";

message StreamRequest {
  int32 userId = 1; // user id
  string current_app_version = 2; // 現在のアプリバージョン
}

message FirmwareUpdate {
  int32 userId = 1;
  string message = 2;
  string new_firmware_version = 3;
}

message AppUpdate {
  int32 userId = 1;
  string message = 2;
  string min_supported_version = 3; // 利用可能な最小バージョン
}

message UpdateVersionResponse {
  optional FirmwareUpdate firmware_update = 1;
  optional AppUpdate app_update = 2;
}

service NotificationService {
  rpc ListenUpdates(stream StreamRequest) returns (stream UpdateVersionResponse);
}

サーバー(Go)

protocコマンドで、gRPCサーバーコードを生成

protocコマンドを使って、protos/notification.protoファイルからpb/ディレクトリにgRPCサーバーのコードを生成する。

-go_opt=paths=source_relativeオプションは、protocコマンドが生成するGoコードのファイルパスを相対パスとして生成することを指定する。これにより、生成されるファイルがソースファイルのディレクトリ構造を維持したまま、指定された出力ディレクトリに出力される。

  • protos/notification.protoファイルから生成されたGoコードは、pb/notification.pb.goとして出力される。
  • gRPCコードも同様にpb/notification_grpc.pb.goとして出力される。
ShellScript
$ protoc --proto_path=protos --go_out=pb --go_opt=paths=source_relative --go-grpc_out=pb --go-grpc_opt=paths=source_relative protos/notification.proto

gRPCサーバーの設定

ListenUpdates メソッド

  • クライアントからのストリームリクエストを受け取り、ユーザーIDと現在のアプリバージョンを取得する。
  • ユーザー情報をデータベースから取得し、現在のファームウェアとアプリのバージョンをチェック。ファームウェアとアプリの更新が必要かどうかを確認し、それに応じてレスポンスを返す。
  • 一定の間隔でチェックを行い、クライアントからのキャンセルリクエストがあればループを抜けます。

checkFirmwareUpdate メソッド

  • SSMパラメータストアから最新のファームウェアバージョンを取得し、現在のバージョンと比較して更新が必要かどうかを確認する。

checkAppUpdate メソッド

  • こちらは、今回動作確認のために、サポート対象となる最小のアプリバージョンを直書き。現在のアプリバージョンと比較して更新が必要かどうかを確認する。
Go
// grpc_notification_service.go
package clocky_be

import (
	"context"
	"log"
	"time"

	"github.com/EarEEG-dev/clocky_be/pb"
	"github.com/jmoiron/sqlx"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// Notification Service Server
type notificationServiceServer struct {
	pb.UnimplementedNotificationServiceServer
	db      *sqlx.DB
	sm      *IotShadowManager
	ssm     *SSMClient
	sqsChan *SQSChannel
}

func (s *notificationServiceServer) ListenUpdates(stream pb.NotificationService_ListenUpdatesServer) error {
	uid, ok := stream.Context().Value("uid").(string)
	if !ok {
		return status.Errorf(codes.Unauthenticated, "uid not found in context")
	}

	log.Printf("received listen update request from user: %s", uid)

	for {
		req, err := stream.Recv()
		if err != nil {
			return status.Errorf(codes.Internal, "Error receiving stream request: %v", err)
		}

		userId := req.UserId

		// DBからユーザー情報を取得する
		user, err := GetUser(s.db, uid)
		if err != nil {
			return err
		}

		currentAppVersion := req.CurrentAppVersion
		currentFirmwareVersion := user.FirmwareVersion

		// ファームウェア更新チェック
		firmwareUpdate := s.checkFirmwareUpdate(userId, currentFirmwareVersion)
		// アプリ更新チェック
		appUpdate := s.checkAppUpdate(userId, currentAppVersion)

		response := &pb.UpdateVersionResponse{}

		if firmwareUpdate != nil {
			response.FirmwareUpdate = firmwareUpdate
		}

		if appUpdate != nil {
			response.AppUpdate = appUpdate
		}

		if err := stream.Send(response); err != nil {
			return status.Errorf(codes.Internal, "Error sending stream response: %v", err)
		}

		time.Sleep(time.Minute)

		select {
		case <-stream.Context().Done():
			// ストリームがキャンセルされた場合はループを抜ける
			log.Printf("stream closed by client")
			return stream.Context().Err()
		default:
			// ループを続ける
		}
	}
}

func (s *notificationServiceServer) checkFirmwareUpdate(userId int32, currentFirmwareVersion string) *pb.FirmwareUpdate {
	// SSMパラメータストアから最新バージョンを取得する
	newFirmwareVersion, err := s.ssm.GetNewFirmwareVersion(context.Background())
	if err != nil {
		log.Printf("failed to get new firmware version: %v", err)
		return nil
	}

	if newFirmwareVersion != currentFirmwareVersion {
		return &pb.FirmwareUpdate{
			UserId:             userId,
			Message:            "New firmware available",
			NewFirmwareVersion: newFirmwareVersion,
		}
	}

	return nil
}

func (s *notificationServiceServer) checkAppUpdate(userId int32, currentAppVersion string) *pb.AppUpdate {
    minSupportedVersion := "0.5.4" // この値はデータベースや設定ファイルから取得
    if currentAppVersion < minSupportedVersion {
        return &pb.AppUpdate{
            UserId:             userId,
            Message:            "App update required",
            MinSupportedVersion: minSupportedVersion,
        }
    }
    return nil
}

アプリ(Flutter):ビジネスロジック

まず、現在のアプリのバージョンを取得してサーバーに通知する機能を実装する。

現在のアプリのバージョンを取得

まず、package_info_plusパッケージを使用して現在のバージョン情報を取得する。

https://pub.dev/packages/package_info_plus

ShellScript
$ flutter pub add package_info_plus
$ flutter pub get

StreamRequestに現在のアプリのバージョン情報を含める

PackageInfo.fromPlatform() を使って現在のアプリのバージョンを取得する。この情報を StreamRequest に渡す。

Dart
// lib/services/grpc_service.dart
final grpcStreamProvider = StreamProvider<StreamResponse>((ref) async* {
  final grpcService = ref.watch(grpcServiceProvider);
  final user = ref.read(userProvider);
  if (user?.uid == null) {
    yield* const Stream.empty();
    return;
  }

  // Get the current app version
  final packageInfo = await PackageInfo.fromPlatform();
  final currentAppVersion = packageInfo.version;

  // Pass the current app version to the gRPC service
  yield* grpcService.listenNotifications(user!.id!, currentAppVersion);
});

protocコマンドでgRPCクライアントコードを生成

notification.protoファイルがprotosディレクトリにあり、生成されたファイルをlib/grpcに出力したいので、下記コマンドを使用して、.protoファイルからDartコードを生成する。

ShellScript
protoc --dart_out=grpc:lib/grpc -I protos protos/notification.proto

gRPCクライアントの設定

listenUpdates メソッド

  • ユーザーIDと現在のアプリのバージョンを使って、サーバーからの更新情報をストリームで受け取る。エラーハンドリングも行い、エラーが発生した場合は再接続を試みる。
Dart
// lib/services/grpc_service.dart
Stream<UpdateVersionResponse> listenUpdates(
    int userId, String currentAppVersion) async* {
  while (true) {
    try {
      final headers = await headersProvider();
      final client = NotificationServiceClient(channel,
          options: CallOptions(
            metadata: headers,
          ));
      final request = StreamRequest()
        ..userId = userId
        ..currentAppVersion = currentAppVersion;
      final requestStream = Stream<StreamRequest>.fromIterable([request]);
      await for (var response in client.listenUpdates(requestStream)) {
        yield response;
        print("listenUpdates response: $response");
      }
    } catch (e) {
      print('Error listening to updates: $e');
      yield* Stream.error(e);
      await Future.delayed(const Duration(seconds: 5));
      print('listenUpdates reconnect...');
    }
  }
}

一旦この時点で、responseでfirmware updateとapp updateが含まれているかを確認する。

Dart
print("listenUpdates response: $response");

出力結果は下記。無事取得できていそうなので、後はアプリでの描画のみ。

ShellScript
flutter: listenUpdates response: firmwareUpdate: {
  userId: 20
  message: New firmware available
  newFirmwareVersion: 0.1.14
}
appUpdate: {
  userId: 20
  message: App update required
  minSupportedVersion: 0.5.4
}

アプリ(Flutter):UI

アップデート通知の状態管理

notificationShownProviderは、通知が表示されたかどうかを管理するための状態プロバイダー。

Dart
// lib/screens/home/home_tab.dart
final notificationShownProvider = StateProvider<bool>((ref) => false);

アプリのアップデートチェック

  • updateVersionResponseを監視し、データが取得されたらアプリとファームウェアのアップデートが必要かどうかをチェックする。
  • response.appUpdate.minSupportedVersion.isNotEmptyを使って、アプリのアップデートが必要かどうかをチェック。
  • アプリのアップデートが必要な場合、showDialogを使ってアップデートを促すモーダルを表示。barrierDismissible: falseを設定することで、ユーザーがモーダルの外側をタップして閉じることを防ぐ。これにより、ユーザーが必ずアップデートを実行する必要がある。
Dart
// lib/screens/home/home_tab.dart
updateVersionResponse.when(
  data: (response) {
    bool isNotificationShown = notificationShown;

    // アプリのアップデートが必要な場合、画面をロックしてモーダル表示
    if (!isNotificationShown &&
        response.appUpdate.minSupportedVersion.isNotEmpty) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        showDialog(
          context: context,
          barrierDismissible: false,
          builder: (BuildContext context) {
            return AlertDialog(
              title: const Text('アプリのアップデートが必要です'),
              content: Text(
                  'バージョン ${response.appUpdate.minSupportedVersion} 以上に更新してください。'),
              actions: <Widget>[
                TextButton(
                  onPressed: () {
                    // アプリストアへ遷移
                    launchAppStore();
                  },
                  child: const Text(
                    "今すぐ更新する",
                    style: TextStyle(
                      color: AppColors.primaryColorDark,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            );
          },
        );
        ref.read(notificationShownProvider.notifier).state = true;
      });
      isNotificationShown = true;
    }

    // ファームウェアアップデートの通知
    if (!isNotificationShown &&
        response.firmwareUpdate.newFirmwareVersion.isNotEmpty) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (!notificationShown) {
          showCustomMessageBar(
            context: context,
            message:
                "新しいファームウェアバージョン(${response.firmwareUpdate.newFirmwareVersion})がダウンロード可能です。",
            onClose: () {},
            onTapped: () {
              showDialog(
                context: navigatorKey.currentState!.context,
                builder: (BuildContext context) {
                  return FirmwareUpdateDialog(
                    newVersion: response.firmwareUpdate.newFirmwareVersion,
                    onConfirm: () {
                      final userNotifier = ref.read(userProvider.notifier);
                      userNotifier.updateUser(User(
                          firmwareVersion:
                              response.firmwareUpdate.newFirmwareVersion));
                    },
                    onCancel: () {},
                  );
                },
              );
            },
            duration: Duration.zero,
          );
          ref.read(notificationShownProvider.notifier).state = true;
        }
      });
      isNotificationShown = true;
    }

    return const SizedBox.shrink();
  },
  loading: () => const CircularProgressIndicator(),
  error: (error, stack) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text("サーバーに再接続中・・・"),
          duration: Duration(seconds: 3),
        ),
      );
    });
    return const SizedBox.shrink();
  },
),

アップデート処理

  • launchAppStoreメソッドを呼び出して、対応するアプリストアのページに遷移させる。
  • Google Play Store, Apple Storeに掲載のappIdをそれぞれ環境変数で指定して、URLのsuffixとして添える。
Dart
// lib/screens/home/home_tab.dart
Future<void> launchAppStore() async {
  const String appId = String.fromEnvironment('appId');
  const String appleId = String.fromEnvironment('appleId');

  const String googlePlayUrl =
      'https://play.google.com/store/apps/details?id=$appId';
  const String appStoreUrl = 'https://apps.apple.com/app/id$appleId';

  final url = defaultTargetPlatform == TargetPlatform.iOS
      ? appStoreUrl
      : googlePlayUrl;

  if (await canLaunchUrl(Uri.parse(url))) {
    print(Uri.parse(url));
    await launchUrl(Uri.parse(url));
  } else {
    throw 'Could not launch $url';
  }
}

動作確認

現在のアプリのバージョンは「0.5.2」。

サーバーで、最小サポートのアプリのバージョンとして「0.5.2」以降の数字を入力した場合に限り、アプリを起動した時に画面がロックされて、強制アップデートのDialogを表示されるかを確認。試しに「0.5.4」をしていたら、無事表示されることを確認した。

また、アップデート実施をクリックしたら、iOS実機の場合はappleストアのアプリページに遷移するかどうかを確認。こちらは、まだアプリを公開していないのでエラーにはなるが、ログのURLが正常に出力されていることを確認した。

ShellScript
flutter: current App version 0.5.2

次に、最小サポートのアプリのバージョンとして「0.5.2」未満の数字を設定した場合には、強制アップデートの通知が表示されないことを確認。Firmwareのアップデートが必要であれば、Firmwareのアップデートのみ通知される。

タイトルとURLをコピーしました