はじめに
前回、こちらの記事でファームウェアのアップデート通知機能を実装した。今回は、アプリの強制アップデート機能を実装する。
実装方法
「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
フィールドを追加し、クライアントがサーバーに現在のアプリのバージョンを通知できるようにした。- 新しい
FirmwareUpdate
とAppUpdate
メッセージを追加し、ファームウェアとアプリの更新情報を個別に含めることができるようにした。 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が含まれているかを確認する。
print("listenUpdates response: $response");
出力結果は下記。無事取得できていそうなので、後はアプリでの描画のみ。
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
は、通知が表示されたかどうかを管理するための状態プロバイダー。
// lib/screens/home/home_tab.dart
final notificationShownProvider = StateProvider<bool>((ref) => false);
アプリのアップデートチェック
updateVersionResponse
を監視し、データが取得されたらアプリとファームウェアのアップデートが必要かどうかをチェックする。response.appUpdate.minSupportedVersion.isNotEmpty
を使って、アプリのアップデートが必要かどうかをチェック。- アプリのアップデートが必要な場合、
showDialog
を使ってアップデートを促すモーダルを表示。barrierDismissible: false
を設定することで、ユーザーがモーダルの外側をタップして閉じることを防ぐ。これにより、ユーザーが必ずアップデートを実行する必要がある。
// 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として添える。
// 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が正常に出力されていることを確認した。
flutter: current App version 0.5.2
次に、最小サポートのアプリのバージョンとして「0.5.2」未満の数字を設定した場合には、強制アップデートの通知が表示されないことを確認。Firmwareのアップデートが必要であれば、Firmwareのアップデートのみ通知される。