🐈

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

2024/05/16に公開

はじめに

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

https://kazulog.fun/dev/esp32-ota-update-app-notification/

実装方法

「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)の両方で変更を適用する。

// 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**として出力される。
$ 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 メソッド

  • こちらは、今回動作確認のために、サポート対象となる最小のアプリバージョンを直書き。現在のアプリバージョンと比較して更新が必要かどうかを確認する。
// 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

$ flutter pub add package_info_plus
$ flutter pub get

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

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

// 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コードを生成する。

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

gRPCクライアントの設定

listenUpdates メソッド

  • ユーザーIDと現在のアプリのバージョンを使って、サーバーからの更新情報をストリームで受け取る。エラーハンドリングも行い、エラーが発生した場合は再接続を試みる。
// 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が含まれているかを確認する。

続きは、こちらで記載しています。
https://kazulog.fun/dev/flutter-go-grpc-app-force-update/

Discussion