[Flutter × Go × gRPC] Implementation of forced application update function (screen lock)

flutter-go-grpc-forced-application-update
This article can be read in about 28 minutes.

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 the StreamRequest message in the proto 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 the StreamRequest message to allow the client to inform the server of the current app version.
  • Added new FirmwareUpdate and AppUpdate 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 to NotificationService 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).

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 output as pb/notification.pb.go.
  • The gRPC code is similarly output as 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 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.
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
}

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

ShellScript
$ 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.

Dart
// 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.

ShellScript
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.
Dart
// 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.

Dart
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.

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
}

Application (Flutter): UI

Update notification status management

notificationShownProvider is a state provider that manages whether or not a notification has been displayed.

Dart
// 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 to false to prevent the user from tapping outside the modal to close it. This requires the user to always perform the update.
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: [
                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.
Dart
// 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.

ShellScript
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.

Copied title and URL