Introduction.
Previously, we implemented the firmware update notification function in this article. This time, we will implement a forced update function for the application.
Mounting Method
A search for “Flutter Firebase app update” yielded two major types of hits: one using RemoteConfig and the other using FirebaseDetabase.
However, this time, since it seems to be possible to implement the same mechanism as the firmware notification we have already made, we will implement it on our own. We will proceed with the following policy.
Server (Go)
- Version comparison: You can get the current app version on the app and compare it to the current required version.
- Notification of new versions: extend and standardize the ListenAppUpdates RPC method, which is the gRPC method of the firmware. Notify the Flutter app when a new firmware or app version is available by comparing it to the version information in the database.
App (Flutter)
- Notify the server of the current app version: add
current_app_version
to theStreamRequest
message in theproto
file. The current app version is obtained using the package_info_plus package. - Receive notifications of new versions: listen for notifications of updates.
- Forced Update: When there is a new update notification, lock the home screen to notify the user of the new update version in a dialog and display a “Update Now” button. The user should not be able to cancel the update.
Fixed gRPC interface
Modify notification.proto file as follows
- Added
current_app_version
field to theStreamRequest
message to allow the client to inform the server of the current app version. - Added new
FirmwareUpdate
andAppUpdate
messages to include firmware and app update information separately. - Added
UpdateVersionResponse
message to allow both firmware and app update information to be returned in a single response. - Added
ListenUpdates
method toNotificationService
service to return a stream of firmware and app updates.
This change would allow the current version of the app to be passed to the server, which would then notify the client of updates to both the app and the firmware.
This change in notification.proto applies the change both in the app (Flutter) and the server (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);
}
Server (Go)
Generate gRPC server code with protoc command
Generate the code for the gRPC server in the pb/
directory from the protos/notification.proto
file using the protoc
command.
The -go_opt=paths=source_relative option specifies that the Go code file paths generated by the protoc command are generated as relative paths. This will cause the generated files to be output to the specified output directory while maintaining the directory structure of the source files.
- Go code generated from the
protos/notification.proto
file is outputas pb/notification.pb.go
. - The gRPC code is similarly output
as 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 server configuration
ListenUpdates
method
- Receives stream requests from clients and retrieves user ID and current app version.
- Retrieve user information from database and check current firmware and app versions. Checks to see if the firmware and app need to be updated and responds accordingly.
- It checks at regular intervals and exits the loop if there is a cancellation request from the client.
checkFirmwareUpdate
method
- Obtain the latest firmware version from the SSM parameter store and compare it with the current version to see if an update is needed.
checkAppUpdate
method
- This is a direct write-up of the smallest supported app version for this operation check. Compare with the current app version to see if an update is needed.
// 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
}
Application (Flutter): Business Logic
First, implement a function to obtain the current version of the application and notify the server.
Get current app version
First, use the package package_info_plus
to obtain the current version information.
https://pub.dev/packages/package_info_plus
$ flutter pub add package_info_plus
$ flutter pub get
Include current app version information in StreamRequest
PackageInfo.fromPlatform()
is used to get the current app version. This information is passed to StreamRequest
.
// lib/services/grpc_service.dart
final grpcStreamProvider = StreamProvider((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);
});
Generate gRPC client code with protoc command
Since the notification.proto
file is in the protos
directory and we want to output the generated file to lib/grpc
, use the following command to generate Dart code from the .proto
file.
protoc --dart_out=grpc:lib/grpc -I protos protos/notification.proto
gRPC Client Configuration
listenUpdates
method
- Streams updates from the server using the user ID and current app version. Error handling is also performed and attempts to reconnect if an error occurs.
// lib/services/grpc_service.dart
Stream 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.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...');
}
}
}
Once at this point, check to see if the firmware update and app update are included in the response.
print("listenUpdates response: $response");
The output result is shown below. It seems to be successfully acquired, so all that remains is to draw it in the application.
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
}
Application (Flutter): UI
Update notification status management
notificationShownProvider
is a state provider that manages whether or not a notification has been displayed.
// lib/screens/home/home_tab.dart
final notificationShownProvider = StateProvider((ref) => false);
Check for app updates
- Monitor
updateVersionResponse
and check if the app and firmware need to be updated when data is retrieved. - Check if the app needs to be updated
using response.appUpdate.minSupportedVersion.isNotEmpty
. - When an app update is required,
use showDialog to
display a modal prompting the user to update.barrierDismissible:
set tofalse
to prevent the user from tapping outside the modal to close it. This requires the user to always perform the update.
// 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: [
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();
},
),
update process
- Call the
launchAppStore
method to move to the corresponding app store page. - Specify the appId listed in the Google Play Store and Apple Store, respectively, in environment variables and attach them as URL suffixes.
// lib/screens/home/home_tab.dart
Future 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';
}
}
operation check
The current version of the application is 0.5.2.
Check to see if the screen locks up and displays the forced update Dialog when the app is launched only if the server entered a number after “0.5.2” as the minimum supported app version. If I had done “0.5.4” as a test, I confirmed that it would be displayed successfully.
Also, after clicking on the update implementation, we checked if it takes us to the app page of the apple store for the actual iOS device. Here, we confirmed that the URL in the log is output correctly, although it is an error since the app has not been published yet.
flutter: current App version 0.5.2
Next, confirm that if a number less than “0.5.2” is set as the minimum supported app version, no forced update notification will appear; if a Firmware update is required, only the Firmware update will be notified.