diff --git a/.github/workflows/starter-commands.yml b/.github/workflows/starter-commands.yml new file mode 100644 index 000000000..e1dd7b058 --- /dev/null +++ b/.github/workflows/starter-commands.yml @@ -0,0 +1,49 @@ +name: Test Commands in Starter + +on: + pull_request: + branches: + - main + +jobs: + test-modules: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: starter + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Flutter SDK + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Set up Node.js + uses: actions/setup-node@v2 + + - name: Install dependencies + run: npm install + + - name: Run hasCamera command + run: npm run hasCamera platform="ios,android" cameraDescription="Hello world" + continue-on-error: false + + - name: Run hasFileManager command + run: npm run hasFileManager platform="ios,android" photoLibraryDescription="Hello" musicDescription="world" + continue-on-error: false + + - name: Run hasContacts command + run: npm run hasContacts contactsDescription="Hello world" platform="ios,android" + continue-on-error: false + + - name: Run hasConnect command + run: npm run hasConnect platform="ios,android" cameraDescription="Hello world" contactsDescription="Hello world" + continue-on-error: false + + - name: Run hasLocation command + run: npm run hasLocation platform="ios,android" locationDescription="Hello world" alwaysUseLocationDescription="Hello world" inUseLocationDescription="Hello world" + continue-on-error: false diff --git a/.gitignore b/.gitignore index 1fa4d8c81..203cb02f4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ pubspec_overrides.yaml /starter/ios/Flutter starter/ios/Runner.xcodeproj/project.pbxproj /node_modules +starter/node_modules +starter/package-lock.json # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 95154edc9..210eb4990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-01-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.14`](#ensemble---v1114) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.14` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + ## 2025-01-03 ### Changes diff --git a/modules/auth/pubspec.yaml b/modules/auth/pubspec.yaml index e35b1d5d2..bd9f31768 100644 --- a/modules/auth/pubspec.yaml +++ b/modules/auth/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: diff --git a/modules/bracket/pubspec.yaml b/modules/bracket/pubspec.yaml index e06568d49..a93a2e6e4 100644 --- a/modules/bracket/pubspec.yaml +++ b/modules/bracket/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble dev_dependencies: diff --git a/modules/camera/pubspec.yaml b/modules/camera/pubspec.yaml index a81bc27bb..50ba52cd6 100644 --- a/modules/camera/pubspec.yaml +++ b/modules/camera/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: git: diff --git a/modules/chat/pubspec.yaml b/modules/chat/pubspec.yaml index af638287c..15548f41b 100644 --- a/modules/chat/pubspec.yaml +++ b/modules/chat/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: diff --git a/modules/connect/pubspec.yaml b/modules/connect/pubspec.yaml index 0670697e6..3e9ee94de 100644 --- a/modules/connect/pubspec.yaml +++ b/modules/connect/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble plaid_flutter: ^3.1.2 diff --git a/modules/contacts/pubspec.yaml b/modules/contacts/pubspec.yaml index 90517bf56..8ff750013 100644 --- a/modules/contacts/pubspec.yaml +++ b/modules/contacts/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble flutter_contacts: ^1.1.7+1 diff --git a/modules/deeplink/pubspec.yaml b/modules/deeplink/pubspec.yaml index 4e99c71e8..b26bc622d 100644 --- a/modules/deeplink/pubspec.yaml +++ b/modules/deeplink/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble flutter_branch_sdk: ^7.0.1 diff --git a/modules/ensemble/CHANGELOG.md b/modules/ensemble/CHANGELOG.md index e448ec903..3d5031e7b 100644 --- a/modules/ensemble/CHANGELOG.md +++ b/modules/ensemble/CHANGELOG.md @@ -1,3 +1,5 @@ +## 1.1.14 + ## 1.1.13 - Moengage Module Release diff --git a/modules/ensemble/lib/action/Log_event_action.dart b/modules/ensemble/lib/action/Log_event_action.dart index da6f113b5..cafef12d8 100644 --- a/modules/ensemble/lib/action/Log_event_action.dart +++ b/modules/ensemble/lib/action/Log_event_action.dart @@ -6,11 +6,12 @@ import 'package:ensemble/framework/logging/log_manager.dart'; import 'package:ensemble/framework/logging/log_provider.dart' as logging; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/moengage_utils.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:moengage_flutter/moengage_flutter.dart'; +// import 'package:moengage_flutter/moengage_flutter.dart'; import 'package:ensemble/framework/stub/moengage_manager.dart'; class LogEvent extends ensembleAction.EnsembleAction { @@ -79,7 +80,8 @@ class LogEvent extends ensembleAction.EnsembleAction { case 'setLocation': case 'setUserAttributeLocation': - final location = getLocation(value); + case 'locationAttribute': + final location = EnsembleGeoLocation.parse(value); if (location == null) { throw LanguageError('Invalid location format'); } @@ -126,20 +128,6 @@ class LogEvent extends ensembleAction.EnsembleAction { return logging.LogLevel.info; } - static MoEGeoLocation? getLocation(dynamic value) { - if (value is Map) { - final lat = Utils.getDouble(value['latitude'], fallback: 0); - final lng = Utils.getDouble(value['longitude'], fallback: 0); - return MoEGeoLocation(lat, lng); - } - - final locationData = Utils.getLatLng(value); - if (locationData != null) { - return MoEGeoLocation(locationData.latitude, locationData.longitude); - } - return null; - } - @override Future execute(BuildContext context, ScopeManager scopeManager) async { try { @@ -222,14 +210,16 @@ class LogEvent extends ensembleAction.EnsembleAction { await moEngage.setBirthDate(Utils.getString(value, fallback: '')); break; case 'setGender': - await moEngage - .setGender(MoEGender.values.from(value) ?? MoEGender.male); + final ensembleGender = EnsembleGender.fromString(value?.toString()); + if (ensembleGender != null) { + await moEngage.setGender(ensembleGender); + } break; case 'setAlias': await moEngage.setAlias(Utils.getString(value, fallback: '')); break; case 'setLocation': - final location = getLocation(value); + final location = EnsembleGeoLocation.parse(value); if (location != null) { await moEngage.setLocation(location); } @@ -244,7 +234,7 @@ class LogEvent extends ensembleAction.EnsembleAction { attributeKey!, Utils.getString(value, fallback: '')); break; case 'locationAttribute': - final location = getLocation(value); + final location = EnsembleGeoLocation.parse(value); if (location != null) { await moEngage.setUserAttributeLocation(attributeKey!, location); } @@ -253,11 +243,11 @@ class LogEvent extends ensembleAction.EnsembleAction { // Tracking Events case 'trackEvent': if (parameters != null) { - final moEProperties = MoEProperties(); + final ensembleProps = EnsembleProperties(); parameters.forEach((key, value) { - moEProperties.addAttribute(key, value); + ensembleProps.addAttribute(key, value); }); - await moEngage.trackEvent(eventName!, moEProperties); + await moEngage.trackEvent(eventName!, ensembleProps); } else { await moEngage.trackEvent(eventName!); } @@ -295,14 +285,19 @@ class LogEvent extends ensembleAction.EnsembleAction { await moEngage.disableAdIdTracking(); break; case 'setAppStatus': - await moEngage.setAppStatus(MoEAppStatus.values.from(value)!); + final status = EnsembleAppStatus.fromString(value?.toString()); + if (status != null) { + await moEngage.setAppStatus(status); + } break; case 'logout': await moEngage.logout(); break; case 'deleteUser': - await moEngage.deleteUser(); - break; + final success = await moEngage.deleteUser(); + if (!success) { + throw Exception('Failed to delete user'); + } // Push Configuration case 'registerForPush': @@ -335,9 +330,9 @@ class LogEvent extends ensembleAction.EnsembleAction { await moEngage.showInApp(); break; case 'showNudge': - await moEngage.showNudge( - position: MoEngageNudgePosition.values.from(value) ?? - MoEngageNudgePosition.bottom); + final position = EnsembleNudgePosition.fromString(value?.toString()) ?? + EnsembleNudgePosition.bottom; + await moEngage.showNudge(position: position); break; case 'setContext': if (value is List) { diff --git a/modules/ensemble/lib/action/action_invokable.dart b/modules/ensemble/lib/action/action_invokable.dart index dea90b0de..ac93e9a36 100644 --- a/modules/ensemble/lib/action/action_invokable.dart +++ b/modules/ensemble/lib/action/action_invokable.dart @@ -39,6 +39,9 @@ abstract class ActionInvokable with Invokable { ActionType.dismissDialog, ActionType.closeAllDialogs, ActionType.executeActionGroup, + ActionType.saveFile, + ActionType.controlDeviceBackNavigation, + ActionType.closeApp, ]); } diff --git a/modules/ensemble/lib/action/close_app.dart b/modules/ensemble/lib/action/close_app.dart new file mode 100644 index 000000000..5e8556ebf --- /dev/null +++ b/modules/ensemble/lib/action/close_app.dart @@ -0,0 +1,24 @@ +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; + +class CloseAppAction extends EnsembleAction { + CloseAppAction({ + super.initiator, + }); + + factory CloseAppAction.from({Invokable? initiator, Map? payload}) { + return CloseAppAction( + initiator: initiator, + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + SystemNavigator.pop(); + return Future.value(null); + } +} diff --git a/modules/ensemble/lib/action/disable_hardware_navigation.dart b/modules/ensemble/lib/action/disable_hardware_navigation.dart new file mode 100644 index 000000000..042ac3ec4 --- /dev/null +++ b/modules/ensemble/lib/action/disable_hardware_navigation.dart @@ -0,0 +1,88 @@ +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ControlBackNavigation extends EnsembleAction { + ControlBackNavigation({ + super.initiator, + this.disable = false, + this.onComplete, + this.onFailure, + }); + + static WillPopCallback? _disableCallback; // Store the callback instance + final bool disable; + final EnsembleAction? onComplete; + final EnsembleAction? onFailure; + + factory ControlBackNavigation.from({Map? payload}) { + return ControlBackNavigation( + disable: payload?['disable'] ?? true, + onComplete: payload?['onComplete'] != null + ? EnsembleAction.from(payload!['onComplete']) + : null, + onFailure: payload?['onFailure'] != null + ? EnsembleAction.from(payload?['onFailure']) + : null, + ); + } + + factory ControlBackNavigation.fromMap(dynamic inputs) => + ControlBackNavigation.from(payload: Utils.getYamlMap(inputs)); + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + try { + // Attach the back button override + _disableBackButton(context); + + if (onComplete != null) { + await ScreenController().executeAction( + context, + onComplete!, + event: EnsembleEvent(initiator, data: { + 'message': disable ? 'Back button disabled' : 'Back button enabled' + }), + ); + } + } catch (e) { + if (onFailure != null) { + await ScreenController().executeAction( + context, + onFailure!, + event: EnsembleEvent(initiator, data: {'error': e.toString()}), + ); + } + rethrow; + } + } + + void _disableBackButton(BuildContext context) { + ModalRoute? currentRoute = ModalRoute.of(context); + if (currentRoute != null) { + if (disable) { + // Check if the callback is already added + if (_disableCallback == null) { + // Create and store the callback + _disableCallback = () async => false; + currentRoute.addScopedWillPopCallback(_disableCallback!); + } + } else { + // Remove the previously added callback + if (_disableCallback != null) { + currentRoute.removeScopedWillPopCallback(_disableCallback!); + _disableCallback = null; // Clear the stored callback + } + } + } else { + throw LanguageError( + "Unable to find the current route to modify back navigation."); + } + } +} diff --git a/modules/ensemble/lib/action/saveFile/download_stub.dart b/modules/ensemble/lib/action/saveFile/download_stub.dart new file mode 100644 index 000000000..e735bcd34 --- /dev/null +++ b/modules/ensemble/lib/action/saveFile/download_stub.dart @@ -0,0 +1,6 @@ +// download_stub.dart +import 'dart:typed_data'; + +void downloadFileOnWeb(String fileName, Uint8List fileBytes) { + throw UnsupportedError('downloadFileOnWeb is not supported on this platform'); +} \ No newline at end of file diff --git a/modules/ensemble/lib/action/saveFile/download_web.dart b/modules/ensemble/lib/action/saveFile/download_web.dart new file mode 100644 index 000000000..c8d96439e --- /dev/null +++ b/modules/ensemble/lib/action/saveFile/download_web.dart @@ -0,0 +1,27 @@ +import 'dart:html' as html; + +import 'package:flutter/foundation.dart'; +Future downloadFileOnWeb(String fileName, Uint8List fileBytes) async { + try { + // Convert Uint8List to a Blob + final blob = html.Blob([fileBytes]); + + // Create an object URL for the Blob + final url = html.Url.createObjectUrlFromBlob(blob); + + // Create a download anchor element + final anchor = html.AnchorElement(href: url) + ..target = 'blank' // Open in a new tab if needed + ..download = fileName; // Set the download file name + + // Trigger the download + anchor.click(); + + // Revoke the object URL to free resources + html.Url.revokeObjectUrl(url); + + debugPrint('File downloaded: $fileName'); + } catch (e) { + throw Exception('Failed to download file: $e'); + } +} \ No newline at end of file diff --git a/modules/ensemble/lib/action/saveFile/save_file.dart b/modules/ensemble/lib/action/saveFile/save_file.dart new file mode 100644 index 000000000..47fc5a1f6 --- /dev/null +++ b/modules/ensemble/lib/action/saveFile/save_file.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:io'; + +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:flutter/material.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +// Conditionally import the file that has `dart:html` vs. the stub: +import 'download_stub.dart' if (dart.library.html) 'download_web.dart'; + +/// Custom action to save files (images and documents) in platform-specific accessible directories +class SaveToFileSystemAction extends EnsembleAction { + final String? fileName; + final dynamic blobData; + final String? source; // Optional source for URL if blobData is not available + final String? type; // file type + + SaveToFileSystemAction({ + required this.fileName, + this.blobData, + this.source, + this.type, + }); + + factory SaveToFileSystemAction.from({Map? payload}) { + if (payload == null || payload['fileName'] == null) { + throw LanguageError('${ActionType.saveFile.name} requires fileName.'); + } + + return SaveToFileSystemAction( + fileName: payload['fileName'], + blobData: payload['blobData'], + source: payload['source'], + type: payload['type'], + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + try { + if (fileName == null) { + throw Exception('Missing required parameter: fileName.'); + } + + Uint8List? fileBytes; + + // If blobData is provided, process it + if (blobData != null) { + // Handle base64 blob or binary data + if (blobData is String) { + fileBytes = base64Decode(blobData); // Decode base64 + } else if (blobData is List) { + fileBytes = Uint8List.fromList(blobData); + } else { + throw Exception( + 'Invalid blob data format. Must be base64 or List.'); + } + } else if (source != null) { + // If blobData is not available, check for source (network URL) + final response = await http.get(Uri.parse(source!)); + if (response.statusCode == 200) { + fileBytes = Uint8List.fromList(response.bodyBytes); + } else { + throw Exception( + 'Failed to download file: HTTP ${response.statusCode}'); + } + } else { + throw Exception('Missing blobData and source.'); + } + + if (type == 'image') { + // Save images to Default Image Path + await _saveImageToDCIM(fileName!, fileBytes); + } else if (type == 'document') { + // Save documents to Documents folder + await _saveDocumentToDocumentsFolder(fileName!, fileBytes); + } + } catch (e) { + throw Exception('Failed to save file: $e'); + } + } + + Future _saveImageToDCIM(String fileName, Uint8List fileBytes) async { + try { + if (kIsWeb) { + _downloadFileOnWeb(fileName, fileBytes); + } else { + final result = await ImageGallerySaver.saveImage( + fileBytes, + name: fileName, + ); + if (result['isSuccess']) { + debugPrint('Image saved to gallery: $result'); + } else { + throw Exception('Failed to save image to gallery.'); + } + } + } catch (e) { + throw Exception('Failed to save image: $e'); + } + } + + /// Save documents to the default "Documents" directory + Future _saveDocumentToDocumentsFolder( + String fileName, Uint8List fileBytes) async { + try { + String filePath; + + if (Platform.isAndroid) { + // Get the default "Documents" directory on Android + Directory? directory = Directory('/storage/emulated/0/Documents'); + if (!directory.existsSync()) { + directory.createSync( + recursive: true); // Create the directory if it doesn't exist + } + filePath = '${directory.path}/$fileName'; + } else if (Platform.isIOS) { + // On iOS, use the app-specific Documents directory + final directory = await getApplicationDocumentsDirectory(); + filePath = '${directory.path}/$fileName'; + + // Optionally, use a share intent to let users save the file to their desired location + } else if (kIsWeb) { + _downloadFileOnWeb(fileName, fileBytes); + return; + } else { + throw UnsupportedError('Platform not supported'); + } + + // Write the file to the determined path + final file = File(filePath); + await file.writeAsBytes(fileBytes); + + debugPrint('Document saved to: $filePath'); + } catch (e) { + throw Exception('Failed to save document: $e'); + } + } + + Future _downloadFileOnWeb(String fileName, Uint8List fileBytes) async { + downloadFileOnWeb(fileName, fileBytes); + } + + /// Factory method to construct the action from JSON + static SaveToFileSystemAction fromJson(Map json) { + return SaveToFileSystemAction( + fileName: json['fileName'], + blobData: json['blobData'], + source: json['source'], + ); + } +} diff --git a/modules/ensemble/lib/framework/action.dart b/modules/ensemble/lib/framework/action.dart index 4abe9582d..537eb977b 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -18,9 +18,12 @@ import 'package:ensemble/action/change_locale_actions.dart'; import 'package:ensemble/action/misc_action.dart'; import 'package:ensemble/action/navigation_action.dart'; import 'package:ensemble/action/notification_actions.dart'; +import 'package:ensemble/action/saveFile/save_file.dart'; import 'package:ensemble/action/phone_contact_action.dart'; import 'package:ensemble/action/sign_in_out_action.dart'; import 'package:ensemble/action/toast_actions.dart'; +import 'package:ensemble/action/disable_hardware_navigation.dart'; +import 'package:ensemble/action/close_app.dart'; import 'package:ensemble/ensemble.dart'; import 'package:ensemble/framework/data_context.dart'; import 'package:ensemble/framework/error_handling.dart'; @@ -59,7 +62,6 @@ class ShowCameraAction extends EnsembleAction { EnsembleAction? onCapture; EnsembleAction? onError; dynamic overlayWidget; - factory ShowCameraAction.fromYaml({Invokable? initiator, Map? payload}) { return ShowCameraAction( @@ -1055,6 +1057,9 @@ enum ActionType { bluetoothDisconnect, bluetoothSubscribeCharacteristic, bluetoothUnsubscribeCharacteristic, + controlDeviceBackNavigation, + closeApp, + saveFile } /// payload representing an Action to do (navigateToScreen, InvokeAPI, ..) @@ -1174,10 +1179,16 @@ abstract class EnsembleAction { return CopyToClipboardAction.from(payload: payload); } else if (actionType == ActionType.share) { return ShareAction.from(payload: payload); + } else if (actionType == ActionType.saveFile) { + return SaveToFileSystemAction.from(payload: payload); + } else if (actionType == ActionType.controlDeviceBackNavigation) { + return ControlBackNavigation.from(payload: payload); } else if (actionType == ActionType.rateApp) { return RateAppAction.from(payload: payload); } else if (actionType == ActionType.getDeviceToken) { return GetDeviceTokenAction.fromMap(payload: payload); + } else if (actionType == ActionType.closeApp) { + return CloseAppAction(); } else if (actionType == ActionType.openPlaidLink) { return PlaidLinkAction.fromYaml(initiator: initiator, payload: payload); } else if (actionType == ActionType.openAppSettings) { diff --git a/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart b/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart index 7802e6fc8..795afde23 100644 --- a/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart @@ -43,7 +43,7 @@ class HTTPAPIProvider extends APIProvider { .authorize(context, oAuthService, scope: scope, forceNewTokens: forceNewTokens); if (token != null) { - headers['Authorization'] = 'Bearer ${token.accessToken}'; + headers['authorization'] = 'Bearer ${token.accessToken}'; } } @@ -54,7 +54,7 @@ class HTTPAPIProvider extends APIProvider { OAuthServiceToken? token = await GetIt.instance().getServiceTokens(serviceName); if (token != null) { - headers['Authorization'] = 'Bearer ${token.accessToken}'; + headers['authorization'] = 'Bearer ${token.accessToken}'; } } } @@ -62,18 +62,19 @@ class HTTPAPIProvider extends APIProvider { if (api['headers'] is YamlMap) { (api['headers'] as YamlMap).forEach((key, value) { // in Web we shouldn't pass the Cookie since that is automatic - if (key.toString() == 'Cookie' && kIsWeb) return; + if (key.toString().toLowerCase() == 'cookie' && kIsWeb) return; if (value != null) { - headers[key.toString()] = eContext.eval(value).toString(); + headers[key.toString().toLowerCase()] = eContext.eval(value).toString(); } }); } // Support JSON (or Yaml) body only. // Here it's converted to YAML already String? bodyPayload; + Uint8List? bodyBytes; if (api['body'] != null) { - final contentType = headers['Content-Type']?.toLowerCase() ?? ''; + final contentType = headers['content-type']?.toLowerCase() ?? ''; if (contentType == 'application/x-www-form-urlencoded') { // For form-urlencoded, convert body to query string format @@ -92,10 +93,14 @@ class HTTPAPIProvider extends APIProvider { // For JSON and other content types try { bodyPayload = json.encode(eContext.eval(api['body'])); - + //this is just to make sure we don't create regressions, we will set + //the bodyBytes only when Content-Type header is explicitly specified. + //see https://github.com/EnsembleUI/ensemble/issues/1823 // set Content-Type as json but don't override user's value if exists - if (headers['Content-Type'] == null) { - headers['Content-Type'] = 'application/json'; + if (headers['content-type'] == null) { + headers['content-type'] = 'application/json'; + } else { + bodyBytes = utf8.encode(bodyPayload); } } on FormatException catch (_, e) { log("Only JSON data supported: " + e.toString()); @@ -149,7 +154,7 @@ class HTTPAPIProvider extends APIProvider { bool manageCookies = Utils.getBool(api['manageCookies'], fallback: false); Completer completer = Completer(); - http.Response response; + http.Response? response; try { http.Client client = await _getHttpClient( @@ -161,39 +166,47 @@ class HTTPAPIProvider extends APIProvider { List cookies = await _cookieJar.loadForRequest(Uri.parse(url)); String cookieString = cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); if (cookieString.isNotEmpty) { - headers['Cookie'] = cookieString; + headers['cookie'] = cookieString; } } - - switch (method) { - case 'POST': - response = - await client.post(Uri.parse(url), headers: headers, body: body); - break; - case 'PUT': - response = - await client.put(Uri.parse(url), headers: headers, body: body); - break; - case 'PATCH': - response = - await client.patch(Uri.parse(url), headers: headers, body: body); - break; - case 'DELETE': - response = - await client.delete(Uri.parse(url), headers: headers, body: body); - break; - case 'GET': - default: - response = await client.get(Uri.parse(url), headers: headers); - break; + if (bodyBytes != null && bodyBytes.isNotEmpty && method != 'GET') { + //we don't want to send body with the GET request as it may cause issues with some servers + //see http spec + http.Request req = http.Request(method, Uri.parse(url)) + ..bodyBytes = bodyBytes + ..headers.addAll(headers); + response = await client.send(req).then(http.Response.fromStream); + } else { + switch (method) { + case 'POST': + response = + await client.post(Uri.parse(url), headers: headers, body: body); + break; + case 'PUT': + response = + await client.put(Uri.parse(url), headers: headers, body: body); + break; + case 'PATCH': + response = + await client.patch(Uri.parse(url), headers: headers, body: body); + break; + case 'DELETE': + response = + await client.delete(Uri.parse(url), headers: headers, body: body); + break; + case 'GET': + default: + response = await client.get(Uri.parse(url), headers: headers); + break; + } } // Store cookies for native apps if (!kIsWeb && manageCookies) { - _cookieJar.saveFromResponse(Uri.parse(url), _extractCookies(response)); + _cookieJar.saveFromResponse(Uri.parse(url), _extractCookies(response!)); } - final isOkay = response.statusCode >= 200 && response.statusCode <= 299; - log('Response: ${response.statusCode}'); + final isOkay = response!.statusCode >= 200 && response!.statusCode <= 299; + log('Response: ${response!.statusCode}'); return HttpResponse(response, isOkay ? APIState.success : APIState.error, apiName: apiName, manageCookies: manageCookies); } catch (e) { diff --git a/modules/ensemble/lib/framework/stub/moengage_manager.dart b/modules/ensemble/lib/framework/stub/moengage_manager.dart index 1bf01db29..6429b6582 100644 --- a/modules/ensemble/lib/framework/stub/moengage_manager.dart +++ b/modules/ensemble/lib/framework/stub/moengage_manager.dart @@ -1,5 +1,5 @@ import 'package:ensemble/framework/error_handling.dart'; -import 'package:moengage_flutter/moengage_flutter.dart'; +import 'package:ensemble/util/moengage_utils.dart'; typedef NotificationCallback = void Function(dynamic data); @@ -8,7 +8,7 @@ abstract class MoEngageModule { Future initialize(String workspaceId, {bool enableLogs = false}); // Event Tracking - Future trackEvent(String eventName, [MoEProperties? properties]); + Future trackEvent(String eventName, [EnsembleProperties? properties]); // User Attributes - Basic Future setUniqueId(String uniqueId); @@ -17,18 +17,18 @@ abstract class MoEngageModule { Future setLastName(String lastName); Future setEmail(String email); Future setPhoneNumber(String phoneNumber); - Future setGender(MoEGender gender); + Future setGender(EnsembleGender gender); Future setBirthDate(String birthDate); - Future setLocation(MoEGeoLocation location); + Future setLocation(EnsembleGeoLocation location); Future setAlias(String alias); // User Attributes - Custom Future setUserAttribute(String attributeName, dynamic value); Future setUserAttributeIsoDate(String attributeName, String date); - Future setUserAttributeLocation(String attributeName, MoEGeoLocation location); + Future setUserAttributeLocation(String attributeName, EnsembleGeoLocation location); // App Status & Tracking - Future setAppStatus(MoEAppStatus status); + Future setAppStatus(EnsembleAppStatus status); Future enableDataTracking(); Future disableDataTracking(); @@ -52,7 +52,7 @@ abstract class MoEngageModule { // InApp & Context Future showInApp(); - Future showNudge({MoEngageNudgePosition position = MoEngageNudgePosition.bottom}); + Future showNudge({EnsembleNudgePosition position = EnsembleNudgePosition.bottom}); Future setCurrentContext(List contexts); Future resetCurrentContext(); Future getSelfHandledInApp(); @@ -74,7 +74,7 @@ abstract class MoEngageModule { Future enableSdk(); Future disableSdk(); Future logout(); - Future deleteUser(); + Future deleteUser(); } class MoEngageModuleStub implements MoEngageModule { @@ -88,7 +88,7 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future trackEvent(String eventName, [MoEProperties? properties]) { + Future trackEvent(String eventName, [EnsembleProperties? properties]) { throw ConfigError(_errorMsg); } @@ -123,7 +123,7 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future setGender(MoEGender gender) { + Future setGender(EnsembleGender gender) { throw ConfigError(_errorMsg); } @@ -133,7 +133,7 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future setLocation(MoEGeoLocation location) { + Future setLocation(EnsembleGeoLocation location) { throw ConfigError(_errorMsg); } @@ -153,12 +153,12 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future setUserAttributeLocation(String attributeName, MoEGeoLocation location) { + Future setUserAttributeLocation(String attributeName, EnsembleGeoLocation location) { throw ConfigError(_errorMsg); } @override - Future setAppStatus(MoEAppStatus status) { + Future setAppStatus(EnsembleAppStatus status) { throw ConfigError(_errorMsg); } @@ -248,7 +248,7 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future showNudge({MoEngageNudgePosition position = MoEngageNudgePosition.bottom}) { + Future showNudge({EnsembleNudgePosition position = EnsembleNudgePosition.bottom}) { throw ConfigError(_errorMsg); } @@ -328,7 +328,7 @@ class MoEngageModuleStub implements MoEngageModule { } @override - Future deleteUser() { + Future deleteUser() { throw ConfigError(_errorMsg); } } \ No newline at end of file diff --git a/modules/ensemble/lib/framework/widget/widget.dart b/modules/ensemble/lib/framework/widget/widget.dart index 682d38b1f..c724303a3 100644 --- a/modules/ensemble/lib/framework/widget/widget.dart +++ b/modules/ensemble/lib/framework/widget/widget.dart @@ -90,7 +90,7 @@ abstract class EWidgetState rtn = AnimatedOpacity( // If visible, apply opacity if specified, else default to 1 opacity: widgetController.visible != false - ? (Utils.getValidOpacity(widgetController.opacity!) ?? 1) + ? (Utils.getValidOpacity(widgetController.opacity ?? 1) ?? 1) : 0, duration: widgetController.visibilityTransitionDuration!, child: rtn); diff --git a/modules/ensemble/lib/util/moengage_utils.dart b/modules/ensemble/lib/util/moengage_utils.dart new file mode 100644 index 000000000..3e856c6eb --- /dev/null +++ b/modules/ensemble/lib/util/moengage_utils.dart @@ -0,0 +1,110 @@ +import 'utils.dart'; + +enum EnsembleGender with EnumParseMixin { + male, + female, + other; + + static EnsembleGender? fromString(String? value) => + EnumParseMixin.fromString(values, value, other); +} + +enum EnsembleAppStatus with EnumParseMixin { + install, + update, + active; + + static EnsembleAppStatus? fromString(String? value) => + EnumParseMixin.fromString(values, value, install); +} + +enum EnsembleNudgePosition with EnumParseMixin { + top, + bottom, + bottomRight, + bottomLeft, + any; + + static EnsembleNudgePosition? fromString(String? value) => + EnumParseMixin.fromString(values, value, bottom); +} + +class EnsembleGeoLocation { + final double latitude; + final double longitude; + + const EnsembleGeoLocation(this.latitude, this.longitude); + + Map toMap() => { + 'latitude': latitude, + 'longitude': longitude + }; + + @override + String toString() => 'EnsembleGeoLocation(lat: $latitude, lng: $longitude)'; + + static EnsembleGeoLocation? parse(dynamic value) { + if (value is Map) { + final lat = Utils.getDouble(value['latitude'], fallback: 0); + final lng = Utils.getDouble(value['longitude'], fallback: 0); + return EnsembleGeoLocation(lat, lng); + } + + final locationData = Utils.getLatLng(value); + if (locationData != null) { + return EnsembleGeoLocation(locationData.latitude, locationData.longitude); + } + return null; + } +} + +class EnsembleProperties { + final Map generalAttributes = {}; + final Map> locationAttributes = {}; + final Map dateTimeAttributes = {}; + bool isNonInteractive = false; + + void addAttribute(String key, dynamic value) { + if (key.isEmpty) return; + + if (value is EnsembleGeoLocation) { + locationAttributes[key] = value.toMap(); + } else if (value != null) { // Only add non-null values + generalAttributes[key] = value; + } + } + + void addISODateTime(String key, String value) { + if (key.isEmpty) return; + dateTimeAttributes[key] = value; + } + + void setNonInteractiveEvent() { + isNonInteractive = true; + } + + Map toMap() => { + 'eventAttributes': { + 'generalAttributes': generalAttributes, + 'locationAttributes': locationAttributes, + 'dateTimeAttributes': dateTimeAttributes, + }, + 'isNonInteractive': isNonInteractive + }; + + // Helper to batch add attributes + void addAttributes(Map attributes) { + attributes.forEach((key, value) => addAttribute(key, value)); + } +} + +// Common mixin for enum parsing +mixin EnumParseMixin { + static T? fromString(List values, String? value, T defaultValue) { + if (value == null) return null; + return values.firstWhere( + (e) => e.name.toLowerCase() == value.toLowerCase(), + orElse: () => defaultValue + ); + } +} \ No newline at end of file diff --git a/modules/ensemble/pubspec.yaml b/modules/ensemble/pubspec.yaml index c5b982bab..a0bcfb4b5 100644 --- a/modules/ensemble/pubspec.yaml +++ b/modules/ensemble/pubspec.yaml @@ -15,7 +15,7 @@ description: Ensemble Runtime # This version is used _only_ for the Runner app, which is used if you just do # a `flutter run` or a `flutter make-host-app-editable`. It has no impact # on any other native host app that you embed your Flutter project into. -version: 1.1.13 +version: 1.1.14 environment: sdk: ">=3.5.0" @@ -93,6 +93,7 @@ dependencies: shared_preferences: ^2.1.1 workmanager: ^0.5.1 flutter_local_notifications: ^17.2.3 + image_gallery_saver: ^2.0.3 flutter_i18n: ^0.35.1 pointer_interceptor: ^0.9.3+4 flutter_secure_storage: ^9.2.2 @@ -141,7 +142,6 @@ dependencies: flutter_slidable: ^3.1.1 accordion: ^2.6.0 session_storage: ^0.0.1 - moengage_flutter: ^8.0.0 dependency_overrides: http: ^0.13.5 diff --git a/modules/ensemble_bluetooth/pubspec.yaml b/modules/ensemble_bluetooth/pubspec.yaml index 642de7089..1bfd3f361 100644 --- a/modules/ensemble_bluetooth/pubspec.yaml +++ b/modules/ensemble_bluetooth/pubspec.yaml @@ -1,7 +1,7 @@ name: ensemble_bluetooth description: An Ensemble package for BLE connection version: 0.0.1 -homepage: https://github.com/EnsembleUI/ensemble_camera +homepage: https://github.com/EnsembleUI/ensemble/modules/ensemble_bluetooth environment: sdk: ">=2.17.0 <=3.10.0" @@ -10,12 +10,11 @@ environment: dependencies: flutter: sdk: flutter - camera: ^0.10.5 # embed Ensemble as a sibling project ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: git: diff --git a/modules/ensemble_network_info/pubspec.yaml b/modules/ensemble_network_info/pubspec.yaml index 82e2e9cef..083c904c2 100644 --- a/modules/ensemble_network_info/pubspec.yaml +++ b/modules/ensemble_network_info/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble network_info_plus: ^5.0.3 diff --git a/modules/file_manager/pubspec.yaml b/modules/file_manager/pubspec.yaml index 219a77bdb..28fe80d0d 100644 --- a/modules/file_manager/pubspec.yaml +++ b/modules/file_manager/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: git: diff --git a/modules/firebase_analytics/pubspec.yaml b/modules/firebase_analytics/pubspec.yaml index df2aec4be..8d03a936e 100644 --- a/modules/firebase_analytics/pubspec.yaml +++ b/modules/firebase_analytics/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble dev_dependencies: diff --git a/modules/location/pubspec.yaml b/modules/location/pubspec.yaml index 516d03a98..f52617b9b 100644 --- a/modules/location/pubspec.yaml +++ b/modules/location/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble ensemble_ts_interpreter: diff --git a/modules/moengage/lib/moengage.dart b/modules/moengage/lib/moengage.dart index cc16f6fee..755a19b2d 100644 --- a/modules/moengage/lib/moengage.dart +++ b/modules/moengage/lib/moengage.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:ensemble/framework/stub/moengage_manager.dart'; +import 'package:ensemble/util/moengage_utils.dart'; import 'package:ensemble_moengage/moengage_notification_handler.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; @@ -28,37 +29,7 @@ class MoEngageImpl implements MoEngageModule { factory MoEngageImpl({required String workspaceId, bool enableLogs = false}) { if (!_instance._initialized) { try { - // Initialize everything here - _instance._workspaceId = workspaceId; - - // 1. Create plugin instance - _instance._moengagePlugin = MoEngageFlutter( - workspaceId, - moEInitConfig: MoEInitConfig( - pushConfig: PushConfig(shouldDeliverCallbackOnForegroundClick: true), - analyticsConfig: AnalyticsConfig( - shouldTrackUserAttributeBooleanAsNumber: false - ) - ) - ); - - // 2. Configure logs if enabled - if (enableLogs) { - _instance._moengagePlugin.configureLogs(LogLevel.VERBOSE); - } - - // 3. Initialize notification handler - final notificationHandler = MoEngageNotificationHandler(); - notificationHandler.initialize(_instance._moengagePlugin); - - // 4. Initialize plugin - _instance._moengagePlugin.initialise(); - - // 5. Request push permissions - _instance._requestInitialPushPermissions(); - - _instance._initialized = true; - debugPrint('MoEngage: Initialization complete'); + _instance._initialize(workspaceId, enableLogs); } catch (e) { debugPrint('MoEngage: Initialization failed: $e'); rethrow; @@ -67,6 +38,39 @@ class MoEngageImpl implements MoEngageModule { return _instance; } + void _initialize(String workspaceId, bool enableLogs) { + _workspaceId = workspaceId; + + // 1. Create plugin instance + _moengagePlugin = MoEngageFlutter( + workspaceId, + moEInitConfig: MoEInitConfig( + pushConfig: PushConfig(shouldDeliverCallbackOnForegroundClick: true), + analyticsConfig: AnalyticsConfig( + shouldTrackUserAttributeBooleanAsNumber: false + ) + ) + ); + + // 2. Configure logs if enabled + if (enableLogs) { + _moengagePlugin.configureLogs(LogLevel.VERBOSE); + } + + // 3. Initialize notification handler + final notificationHandler = MoEngageNotificationHandler(); + notificationHandler.initialize(_moengagePlugin); + + // 4. Initialize plugin + _moengagePlugin.initialise(); + + // 5. Request push permissions + _requestInitialPushPermissions(); + + _initialized = true; + debugPrint('MoEngage: Initialization complete'); + } + @override Future initialize(String workspaceId, {bool enableLogs = false}) async { if (_initialized) { @@ -75,33 +79,7 @@ class MoEngageImpl implements MoEngageModule { } try { - _workspaceId = workspaceId; - - // 1. Create plugin instance - _moengagePlugin = MoEngageFlutter(workspaceId, - moEInitConfig: MoEInitConfig( - pushConfig: - PushConfig(shouldDeliverCallbackOnForegroundClick: true), - analyticsConfig: AnalyticsConfig( - shouldTrackUserAttributeBooleanAsNumber: false))); - - // 2. Configure logs BEFORE notification handler initialization - if (enableLogs) { - _moengagePlugin.configureLogs(LogLevel.VERBOSE); - } - - // 3. Initialize notification handler BEFORE plugin initialization - final notificationHandler = MoEngageNotificationHandler(); - await notificationHandler.initialize(_moengagePlugin); // Make this await - - // 4. Initialize plugin after callbacks are registered - _moengagePlugin.initialise(); - - // 5. Request push permissions if needed - await _requestInitialPushPermissions(); - - _initialized = true; - debugPrint('MoEngage: Initialization complete'); + _initialize(workspaceId, enableLogs); } catch (e) { debugPrint('MoEngage: Initialization failed: $e'); _initialized = false; @@ -133,9 +111,13 @@ class MoEngageImpl implements MoEngageModule { } @override - Future trackEvent(String eventName, [MoEProperties? properties]) async { + Future trackEvent(String eventName, [EnsembleProperties? properties]) async { _checkInitialization(); - _moengagePlugin.trackEvent(eventName, properties); + if (properties != null) { + _moengagePlugin.trackEvent(eventName, _convertProperties(properties)); + } else { + _moengagePlugin.trackEvent(eventName); + } } @override @@ -175,9 +157,9 @@ class MoEngageImpl implements MoEngageModule { } @override - Future setGender(MoEGender gender) async { + Future setGender(EnsembleGender gender) async { _checkInitialization(); - _moengagePlugin.setGender(gender); + _moengagePlugin.setGender(_convertGender(gender)); } @override @@ -187,9 +169,9 @@ class MoEngageImpl implements MoEngageModule { } @override - Future setLocation(MoEGeoLocation location) async { + Future setLocation(EnsembleGeoLocation location) async { _checkInitialization(); - _moengagePlugin.setLocation(location); + _moengagePlugin.setLocation(_convertLocation(location)); } @override @@ -213,15 +195,15 @@ class MoEngageImpl implements MoEngageModule { @override Future setUserAttributeLocation( - String attributeName, MoEGeoLocation location) async { + String attributeName, EnsembleGeoLocation location) async { _checkInitialization(); - _moengagePlugin.setUserAttributeLocation(attributeName, location); + _moengagePlugin.setUserAttributeLocation(attributeName, _convertLocation(location)); } @override - Future setAppStatus(MoEAppStatus status) async { + Future setAppStatus(EnsembleAppStatus status) async { _checkInitialization(); - _moengagePlugin.setAppStatus(status); + _moengagePlugin.setAppStatus(_convertAppStatus(status)); } @override @@ -327,10 +309,9 @@ class MoEngageImpl implements MoEngageModule { } @override - Future showNudge( - {MoEngageNudgePosition position = MoEngageNudgePosition.bottom}) async { + Future showNudge({EnsembleNudgePosition position = EnsembleNudgePosition.bottom}) async { _checkInitialization(); - _moengagePlugin.showNudge(position: position); + _moengagePlugin.showNudge(position: _convertNudgePosition(position)); } @override @@ -424,8 +405,65 @@ class MoEngageImpl implements MoEngageModule { } @override - Future deleteUser() async { - _checkInitialization(); - return _moengagePlugin.deleteUser(); - } +Future deleteUser() async { + _checkInitialization(); + final response = await _moengagePlugin.deleteUser(); + return response.isSuccess; +} } + +extension EnsembleTypeConversion on MoEngageImpl { + MoEGender _convertGender(EnsembleGender gender) { + switch (gender) { + case EnsembleGender.male: return MoEGender.male; + case EnsembleGender.female: return MoEGender.female; + case EnsembleGender.other: return MoEGender.other; + } + } + + MoEAppStatus _convertAppStatus(EnsembleAppStatus status) { + switch (status) { + case EnsembleAppStatus.install: return MoEAppStatus.install; + case EnsembleAppStatus.update: return MoEAppStatus.update; + case EnsembleAppStatus.active: return MoEAppStatus.install; + } + } + + MoEngageNudgePosition _convertNudgePosition(EnsembleNudgePosition position) { + switch (position) { + case EnsembleNudgePosition.top: return MoEngageNudgePosition.top; + case EnsembleNudgePosition.bottom: return MoEngageNudgePosition.bottom; + case EnsembleNudgePosition.bottomRight: return MoEngageNudgePosition.bottomRight; + case EnsembleNudgePosition.bottomLeft: return MoEngageNudgePosition.bottomLeft; + case EnsembleNudgePosition.any: return MoEngageNudgePosition.any; + } + } + + MoEGeoLocation _convertLocation(EnsembleGeoLocation location) { + return MoEGeoLocation(location.latitude, location.longitude); + } + + MoEProperties _convertProperties(EnsembleProperties props) { + final moeProps = MoEProperties(); + + // Add all attributes in batch + props.generalAttributes.forEach((key, value) { + moeProps.addAttribute(key, value); + }); + + props.locationAttributes.forEach((key, value) { + final location = MoEGeoLocation(value['latitude']!, value['longitude']!); + moeProps.addAttribute(key, location); + }); + + props.dateTimeAttributes.forEach((key, value) { + moeProps.addISODateTime(key, value); + }); + + if (props.isNonInteractive) { + moeProps.setNonInteractiveEvent(); + } + + return moeProps; + } +} \ No newline at end of file diff --git a/modules/moengage/pubspec.yaml b/modules/moengage/pubspec.yaml index db79b05f0..55a1f9747 100644 --- a/modules/moengage/pubspec.yaml +++ b/modules/moengage/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble moengage_flutter: ^8.0.0 diff --git a/starter/android/build.gradle b/starter/android/build.gradle index e90901803..fff94a155 100644 --- a/starter/android/build.gradle +++ b/starter/android/build.gradle @@ -20,10 +20,33 @@ allprojects { rootProject.buildDir = '../build' subprojects { + afterEvaluate { project -> + if (project.extensions.findByName("android") != null) { + Integer pluginCompileSdk = project.android.compileSdk + if (pluginCompileSdk != null && pluginCompileSdk < 34) { + + def javaVersion = JavaVersion.VERSION_17 + project.android { + compileSdk 34 + if (namespace == null) { + namespace project.group + } + compileOptions { + sourceCompatibility javaVersion + targetCompatibility javaVersion + } + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = javaVersion.toString() + } + } + } + } + } + } + project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') + project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { diff --git a/starter/ios/Podfile b/starter/ios/Podfile index 2dfd12a76..68bab5712 100644 --- a/starter/ios/Podfile +++ b/starter/ios/Podfile @@ -34,7 +34,7 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '11.4.0' + pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '11.6.0' target 'RunnerTests' do inherit! :search_paths end diff --git a/starter/ios/Runner/Info.plist b/starter/ios/Runner/Info.plist index 0b3eedd7f..5a5f1ed2b 100644 --- a/starter/ios/Runner/Info.plist +++ b/starter/ios/Runner/Info.plist @@ -71,5 +71,9 @@ filza activator + + + ITSAppUsesNonExemptEncryption + diff --git a/starter/ios/Runner/Runner.entitlements b/starter/ios/Runner/Runner.entitlements index c6140582f..96787e073 100644 --- a/starter/ios/Runner/Runner.entitlements +++ b/starter/ios/Runner/Runner.entitlements @@ -4,10 +4,10 @@ aps-environment development - com.apple.developer.applesignin - + + com.apple.developer.associated-domains applinks:app.ensembleui.com diff --git a/starter/lib/generated/ensemble_modules.dart b/starter/lib/generated/ensemble_modules.dart index 264850d4a..a27c798ba 100644 --- a/starter/lib/generated/ensemble_modules.dart +++ b/starter/lib/generated/ensemble_modules.dart @@ -53,6 +53,10 @@ import 'package:get_it/get_it.dart'; // Uncomment to enable deeplink services // import 'package:ensemble_deeplink/deferred_link_manager.dart'; +// Uncomment to enable push notifications services or Firebase Analytics +// import 'package:flutter/foundation.dart'; +// import 'dart:io'; + /// TODO: This class should be generated to enable selected Services class EnsembleModules { static final EnsembleModules _instance = EnsembleModules._internal(); @@ -168,13 +172,13 @@ class EnsembleModules { if (enableChat) { // Uncomment to enable ensemble chat - // GetIt.I.registerSingleton(EnsembleChatImpl()); + // GetIt.I.registerSingleton(EnsembleChatImpl.build(null)); } else { GetIt.I.registerSingleton(const EnsembleChatStub()); } if (useFirebaseAnalytics) { //uncomment to enable firebase analytics - //GetIt.I.registerSingleton(FirebaseAnalyticsProvider()); + // GetIt.I.registerSingleton(FirebaseAnalyticsProvider()); } else { GetIt.I.registerSingleton(LogProviderStub()); } @@ -194,8 +198,7 @@ class EnsembleModules { } if (useBluetooth) { - - //GetIt.I.registerSingleton(BluetoothManagerImpl()); + //GetIt.I.registerSingleton(BluetoothManagerImpl()); } else { GetIt.I.registerSingleton(BluetoothManagerStub()); } diff --git a/starter/package.json b/starter/package.json new file mode 100644 index 000000000..c52baf2eb --- /dev/null +++ b/starter/package.json @@ -0,0 +1,46 @@ +{ + "name": "starter", + "version": "1.0.0", + "description": "This starter project enables running and deploying Ensemble-powered Apps across iOS, Android, and Web (other platforms are not yet fully supported). It also includes examples on how to integrate Ensemble pages into your existing Flutter App.", + "main": "index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "build": "tsc", + "enable": "ts-node src/dart_runner.ts enable", + "hasCamera": "ts-node src/dart_runner.ts camera", + "hasFileManager": "ts-node src/dart_runner.ts file_manager", + "hasContacts": "ts-node src/dart_runner.ts contacts", + "hasConnect": "ts-node src/dart_runner.ts plaid_connect", + "hasLocation": "ts-node src/dart_runner.ts location", + "hasDeeplink": "ts-node src/dart_runner.ts deeplink", + "hasFirebaseAnalytics": "ts-node src/dart_runner.ts firebase_analytics", + "hasMoengage": "ts-node src/dart_runner.ts moengage", + "hasNotification": "ts-node src/dart_runner.ts notification", + "hasBracket": "ts-node src/dart_runner.ts bracket", + "hasNetworkInfo": "ts-node src/dart_runner.ts network_info", + "hasChat": "ts-node src/dart_runner.ts ai_chat", + "hasAuth": "ts-node src/dart_runner.ts auth", + "hasBluetooth": "ts-node src/dart_runner.ts bluetooth", + "hasBiometric": "ts-node src/dart_runner.ts biometric", + "qrCodeEnabled": "ts-node src/dart_runner.ts qr_code", + "hasGoogleMaps": "ts-node src/dart_runner.ts google_maps", + "generate_keystore": "ts-node src/dart_runner.ts generateKeystore" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "inquirer": "^12.0.0", + "prompts": "^2.4.2" + }, + "devDependencies": { + "@types/inquirer": "^9.0.7", + "@types/node": "^22.10.2", + "@types/prompts": "^2.4.9", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + } +} diff --git a/starter/pubspec.yaml b/starter/pubspec.yaml index 9b5cf70c8..9125823ed 100644 --- a/starter/pubspec.yaml +++ b/starter/pubspec.yaml @@ -2,7 +2,7 @@ name: ensemble_starter description: Ensemble Starter project in Flutter # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +publish_to: 'none' # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -36,7 +36,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.1.13 + ref: ensemble-v1.1.14 path: modules/ensemble @@ -103,12 +103,10 @@ dependencies: # Uncomment to enable firebase analytics # ensemble_firebase_analytics: -# git: -# url: https://github.com/EnsembleUI/ensemble.git -# ref: main -# path: modules/firebase_analytics - - + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/firebase_analytics # Uncomment to enable ensemble chat widget # ensemble_chat: @@ -124,14 +122,12 @@ dependencies: # ref: main # path: modules/bracket - # Uncomment to enable NetworkInfo -# ensemble_network_info: -# git: -# url: https://github.com/EnsembleUI/ensemble.git -# ref: main -# path: modules/ensemble_network_info - + # ensemble_network_info: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_network_info # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/starter/scripts/constants.dart b/starter/scripts/constants.dart new file mode 100644 index 000000000..21ea56664 --- /dev/null +++ b/starter/scripts/constants.dart @@ -0,0 +1,14 @@ +const firebaseProguardRules = ''' +# Keep Google Play Services classes +-keep class com.google.android.gms.** { *; } +-keep interface com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# Keep Firebase-related classes +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** + +# Keep other necessary classes for Flutter +-keep class io.flutter.** { *; } +-dontwarn io.flutter.** +'''; diff --git a/starter/scripts/firebase_performance.dart b/starter/scripts/firebase_performance.dart new file mode 100644 index 000000000..4d048f07d --- /dev/null +++ b/starter/scripts/firebase_performance.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'utils.dart'; +import 'utils/firebase_utils.dart'; + +// Adds the Firebase Performance SDK to the project +void main(List arguments) { + List platforms = getPlatforms(arguments); + + try { + addDependency('firebase_performance', '^0.10.0+11'); + + if (platforms.contains('android')) { + addClasspathDependency( + "classpath 'com.google.firebase:perf-plugin:1.4.2'"); + addPluginDependency("apply plugin: 'com.google.firebase.firebase-perf'"); + } + + // Configure iOS-specific settings + if (platforms.contains('ios')) { + addPod('FirebasePerformance'); + } + + print( + 'Firebase Performance SDK enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} + +void addPod(String pod) { + final podfile = File('ios/Podfile'); + if (!podfile.existsSync()) { + throw 'ios/Podfile not found'; + } + + final lines = podfile.readAsLinesSync(); + final newLines = []; + bool added = false; + + for (var line in lines) { + newLines.add(line); + if (line.contains('use_frameworks!')) { + newLines.add(" pod '$pod'"); + added = true; + } + } + + if (!added) { + throw 'use_frameworks! not found in ios/Podfile'; + } + + podfile.writeAsStringSync(newLines.join('\n')); +} + +void addDependency(String dependency, String version) { + final pubspec = File(pubspecFilePath); + if (!pubspec.existsSync()) { + throw 'pubspec.yaml not found'; + } + + final content = pubspec.readAsStringSync(); + final dependenciesSection = + RegExp(r'dependencies:', multiLine: true).firstMatch(content); + + if (dependenciesSection == null) { + throw 'dependencies section not found in pubspec.yaml'; + } + + final dependencyPattern = RegExp(r'\s+$dependency:\s+\S+'); + if (dependencyPattern.hasMatch(content)) { + return; + } + final newContent = content.replaceFirst( + dependenciesSection.group(0)!, + '${dependenciesSection.group(0)}\n $dependency: $version\n', + ); + + pubspec.writeAsStringSync(newContent); +} diff --git a/starter/scripts/generate_keystore.dart b/starter/scripts/generate_keystore.dart new file mode 100644 index 000000000..fcaebcfa4 --- /dev/null +++ b/starter/scripts/generate_keystore.dart @@ -0,0 +1,86 @@ +import 'dart:io'; +import 'utils.dart'; + +void main(List arguments) async { + try { + // Parse the arguments + String? storePassword = getArgumentValue(arguments, 'storePassword'); + String? keyPassword = getArgumentValue(arguments, 'keyPassword'); + String? keyAlias = getArgumentValue(arguments, 'keyAlias'); + + if (storePassword == null || keyPassword == null || keyAlias == null) { + throw Exception( + 'Missing required arguments. Usage: npm run generate_keystore storePassword= keyPassword= keyAlias='); + } + + // Ensure passwords are at least 6 characters long to avoid issues with keytool + if (storePassword.length < 6 || keyPassword.length < 6) { + throw Exception( + 'storePassword and keyPassword must be at least 6 characters long.'); + } + + // Define paths for the keystore and key.properties files + String androidAppDir = Directory.current.path + '/android/app'; + String androidDir = Directory.current.path + '/android'; + String keystorePath = '$androidAppDir/keystore.jks'; + String keyPropertiesPath = '$androidDir/key.properties'; + + // Check if the keystore file already exists + if (File(keystorePath).existsSync()) { + print('Keystore already exists at $keystorePath'); + exit(0); + } + + // Ensure that the directories exist + Directory(androidAppDir).createSync(recursive: true); + Directory(androidDir).createSync(recursive: true); + + // Generate the keystore using the keytool command + String command = 'keytool'; + List args = [ + '-genkey', + '-v', + '-keystore', + keystorePath, + '-alias', + keyAlias, + '-keyalg', + 'RSA', + '-keysize', + '2048', + '-validity', + '9125', + '-storepass', + storePassword, + '-keypass', + keyPassword, + '-dname', + 'CN=Unknown, OU=Unknown, O=Unknown, L=Unknown, S=Unknown, C=US' + ]; + + ProcessResult result = await Process.run(command, args); + + if (result.exitCode != 0) { + throw Exception( + 'Error generating keystore. Exit code: ${result.exitCode}\nError: ${result.stderr}'); + } + + print(result.stdout); + + // Create the key.properties file with the keystore configuration + String keyPropertiesContent = ''' +storePassword=$storePassword +keyPassword=$keyPassword +keyAlias=$keyAlias +storeFile=keystore.jks +'''; + + File keyPropertiesFile = File(keyPropertiesPath); + keyPropertiesFile.writeAsStringSync(keyPropertiesContent.trim()); + + print('Keystore generated successfully!'); + } catch (e) { + stderr.writeln('An error occurred: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_auth.dart b/starter/scripts/modules/enable_auth.dart new file mode 100644 index 000000000..412743aa4 --- /dev/null +++ b/starter/scripts/modules/enable_auth.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import '../constants.dart'; +import '../utils.dart'; +import '../utils/firebase_utils.dart'; +import '../utils/proguard_utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + + // Extract client ID values from the arguments + String iOSClientId = getArgumentValue(arguments, 'googleIOSClientId') ?? ''; + String androidClientId = + getArgumentValue(arguments, 'googleAndroidClientId') ?? ''; + String webClientId = getArgumentValue(arguments, 'googleWebClientId') ?? ''; + String serverClientId = + getArgumentValue(arguments, 'googleServerClientId') ?? ''; + + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_auth/auth_module.dart';", + 'GetIt.I.registerSingleton(AuthModuleImpl());', + ], + 'useStatements': [ + 'static const useAuth = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_auth: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/auth''', + 'regex': + r'#\s*ensemble_auth:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/auth', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Update the auth module configuration in ensemble-config.yaml + updateAuthConfig(iOSClientId, androidClientId, webClientId, serverClientId); + updateFirebaseConfig(platforms, arguments); + if (platforms.contains('android')) { + createProguardRules(firebaseProguardRules); + addClasspathDependency( + "classpath 'com.google.gms:google-services:4.3.15'"); + addPluginDependency("apply plugin: 'com.google.gms.google-services'"); + addSettingsPluginDependency( + 'id "com.google.gms.google-services" version "4.3.15" apply false'); + addImplementationDependency( + "implementation platform('com.google.firebase:firebase-bom:32.7.0')"); + } + + // Update the iOS Info.plist + if (platforms.contains('ios') && iOSClientId.isNotEmpty) { + updateInfoPlist(iOSClientId); + } + + if (platforms.contains('web') && webClientId.isNotEmpty) { + updateHtmlFile('', + '', + removalPattern: + r''); + } + + print( + 'Auth module enabled and configuration updated successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} + +void updateAuthConfig(String iOSClientId, String androidClientId, + String webClientId, String serverClientId) { + try { + final file = File(ensembleConfigFilePath); + if (!file.existsSync()) { + throw Exception('Config file not found.'); + } + + String content = file.readAsStringSync(); + + // Define a map of client IDs and their corresponding config keys + final clientIds = { + 'iOSClientId': iOSClientId, + 'androidClientId': androidClientId, + 'webClientId': webClientId, + 'serverClientId': serverClientId, + }; + + // Replace each client ID if it's not empty + clientIds.forEach((key, value) { + if (value.isNotEmpty) { + content = content.replaceAllMapped( + RegExp('$key:\\s*.*', multiLine: true), + (match) => '$key: $value', + ); + } else { + content = content.replaceAllMapped( + RegExp('$key:\\s*.*', multiLine: true), + (match) => '', + ); + } + }); + + file.writeAsStringSync(content); + } catch (e) { + throw Exception( + 'Failed to update auth configuration in ensemble-config.yaml: $e'); + } +} + +void updateInfoPlist(String iOSClientId) { + try { + final file = File(iosInfoPlistFilePath); + if (!file.existsSync()) { + throw Exception('Info.plist file not found.'); + } + + String content = file.readAsStringSync(); + + final cleanedClientId = + iOSClientId.replaceAll('.apps.googleusercontent.com', ''); + + final reversedClientId = 'com.googleusercontent.apps.$cleanedClientId'; + + // Replace the current iOS client ID in the Info.plist file + content = content.replaceAllMapped( + RegExp( + r'com\.googleusercontent\.apps\.\d+-[a-zA-Z0-9]+'), + (match) => '$reversedClientId', + ); + + file.writeAsStringSync(content); + } catch (e) { + throw Exception('Failed to update Info.plist: $e'); + } +} diff --git a/starter/scripts/modules/enable_biometric.dart b/starter/scripts/modules/enable_biometric.dart new file mode 100644 index 000000000..f320f3906 --- /dev/null +++ b/starter/scripts/modules/enable_biometric.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + + final androidPermissions = [ + '', + ]; + + final iOSPermissions = [ + { + 'key': 'faceIdDescription', + 'value': 'NSFaceIDUsageDescription', + } + ]; + + try { + // Add the biometric permission to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + + // Update MainActivity to extend FlutterFragmentActivity + final mainActivityUpdates = { + 'kotlin': [ + { + 'pattern': + r'import\s+io\.flutter\.embedding\.android\.FlutterActivity', + 'replacement': + 'import io.flutter.embedding.android.FlutterFragmentActivity' + }, + { + 'pattern': r'class\s+MainActivity\s*:\s*FlutterActivity\(\)', + 'replacement': 'class MainActivity: FlutterFragmentActivity()' + } + ], + 'java': [ + { + 'pattern': + r'import\s+io\.flutter\.embedding\.android\.FlutterActivity;', + 'replacement': + 'import io.flutter.embedding.android.FlutterFragmentActivity;' + }, + { + 'pattern': + r'public\s+class\s+MainActivity\s+extends\s+FlutterActivity', + 'replacement': + 'public class MainActivity extends FlutterFragmentActivity' + } + ] + }; + + updateMainActivity(mainActivityUpdates); + } + + // Add Face ID usage description to Info.plist for iOS + if (platforms.contains('ios')) { + updateIOSPermissions(iOSPermissions, arguments); + } + + print('Biometric enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_bluetooth.dart b/starter/scripts/modules/enable_bluetooth.dart new file mode 100644 index 000000000..4d0a91989 --- /dev/null +++ b/starter/scripts/modules/enable_bluetooth.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_bluetooth/ensemble_bluetooth.dart';", + 'GetIt.I.registerSingleton(BluetoothManagerImpl());', + ], + 'useStatements': [ + 'static const useBluetooth = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_bluetooth: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/ensemble_bluetooth''', + 'regex': + r'#\s*ensemble_bluetooth:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/ensemble_bluetooth', + } + ]; + + final androidPermissions = [ + // New Android 12 Bluetooth permissions + '', + '', + '', + // legacy for Android 9 or lower + '', + // Legacy permissions for Android 11 or lower + '', + '', + // Tell Play Store app uses Bluetooth LE + '', + ]; + + final iOSPermissions = [ + { + 'key': 'bluetoothDescription', + 'value': 'NSBluetoothAlwaysUsageDescription', + }, + { + 'key': 'bluetoothPeripheralDescription', + 'value': 'NSBluetoothPeripheralUsageDescription', + } + ]; + + final iOSAdditionalSettings = [ + { + 'key': 'UIBackgroundModes', + 'value': ['bluetooth-central', 'bluetooth-peripheral'], + 'isArray': true, + }, + { + 'key': 'NSBluetoothServices', + 'value': ['180A', '180F', '1812'], + 'isArray': true, + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add required permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // Add required permissions to Info.plist for iOS + if (platforms.contains('ios')) { + updateIOSPermissions( + iOSPermissions, + arguments, + additionalSettings: iOSAdditionalSettings, + ); + } + + print( + 'Bluetooth module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_bracket.dart b/starter/scripts/modules/enable_bracket.dart new file mode 100644 index 000000000..c51746042 --- /dev/null +++ b/starter/scripts/modules/enable_bracket.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_bracket/ensemble_bracket.dart';", + 'GetIt.I.registerSingleton(EnsembleBracketImpl.build());', + ], + 'useStatements': [ + 'static const useBracket = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_bracket: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/bracket''', + 'regex': + r'#\s*ensemble_bracket:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/bracket', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + print( + 'Bracket module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_camera.dart b/starter/scripts/modules/enable_camera.dart new file mode 100644 index 000000000..2ed192fb6 --- /dev/null +++ b/starter/scripts/modules/enable_camera.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import '../utils.dart'; + +Future main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final cameraStatements = { + 'moduleStatements': [ + "import 'package:ensemble_camera/camera_manager.dart';", + "GetIt.I.registerSingleton(CameraManagerImpl());", + ], + 'useStatements': [ + 'static const useCamera = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_camera: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/camera''', + 'regex': + r'#\s*ensemble_camera:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/camera', + } + ]; + + final iOSPermissions = [ + { + 'key': 'cameraDescription', + 'value': 'NSCameraUsageDescription', + }, + { + 'key': 'photoLibraryDescription', + 'value': 'NSPhotoLibraryUsageDescription', + }, + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + cameraStatements['moduleStatements'], + cameraStatements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add the camera permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: [ + '' + ]); + } + + // Add the camera usage description to the iOS Info.plist file + if (platforms.contains('ios')) { + updateIOSPermissions(iOSPermissions, arguments); + } + + print('Camera module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_chat.dart b/starter/scripts/modules/enable_chat.dart new file mode 100644 index 000000000..d28b9a25a --- /dev/null +++ b/starter/scripts/modules/enable_chat.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_chat/ensemble_chat.dart';", + 'GetIt.I.registerSingleton(EnsembleChatImpl.build(null));', + ], + 'useStatements': [ + 'static const enableChat = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_chat: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/chat''', + 'regex': + r'#\s*ensemble_chat:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/chat', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + print('Chat module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_connect.dart b/starter/scripts/modules/enable_connect.dart new file mode 100644 index 000000000..63e306923 --- /dev/null +++ b/starter/scripts/modules/enable_connect.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import '../utils.dart'; +import '../utils/firebase_utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_connect/plaid_link/plaid_link_manager.dart';", + "GetIt.I.registerSingleton(PlaidLinkManagerImpl());", + ], + 'useStatements': [ + 'static const useConnect = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_connect: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/connect''', + 'regex': + r'#\s*ensemble_connect:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/connect', + } + ]; + + final iOSPermissions = [ + { + 'key': 'cameraDescription', + 'value': 'NSCameraUsageDescription', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + updatePubspec(pubspecDependencies); + + if (platforms.contains('android')) { + addImplementationDependency( + "implementation 'org.openjsse:openjsse:1.1.10'"); + addImplementationDependency( + "implementation 'org.conscrypt:conscrypt-android:2.5.2'"); + updateBuildGradle(minifyEnabled: false, shrinkResources: false); + } + + if (platforms.contains('ios')) { + updateIOSPermissions(iOSPermissions, arguments); + } + + // Add the ', + removalPattern: + r'https://cdn\.plaid\.com/link/v2/stable/link-initialize\.js', + ); + } + + print( + 'Connect module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} + +void updateBuildGradle( + {bool minifyEnabled = false, bool shrinkResources = false}) { + final buildGradleFile = File('android/app/build.gradle'); + String content = buildGradleFile.readAsStringSync(); + + if (!content.contains('minifyEnabled')) { + content = content.replaceAllMapped( + RegExp(r'buildTypes\s*{[^}]*release\s*{', multiLine: true), + (match) => + "buildTypes {\n release {\n minifyEnabled $minifyEnabled"); + } + + if (!content.contains('shrinkResources')) { + content = content.replaceAllMapped( + RegExp(r'buildTypes\s*{[^}]*release\s*{', multiLine: true), + (match) => + "buildTypes {\n release {\n shrinkResources $shrinkResources"); + } + + buildGradleFile.writeAsStringSync(content); +} diff --git a/starter/scripts/modules/enable_contacts.dart b/starter/scripts/modules/enable_contacts.dart new file mode 100644 index 000000000..6471ae32a --- /dev/null +++ b/starter/scripts/modules/enable_contacts.dart @@ -0,0 +1,68 @@ +import 'dart:io'; +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final contactsStatements = { + 'moduleStatements': [ + "import 'package:ensemble_contacts/contact_manager.dart';", + "GetIt.I.registerSingleton(ContactManagerImpl());" + ], + 'useStatements': ["static const useContacts = true;"], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_contacts: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/contacts''', + 'regex': + r'#\s*ensemble_contacts:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/contacts', + } + ]; + + final androidPermissions = [ + '', + '', + ]; + + final iOSPermissions = [ + { + 'key': 'contactsDescription', + 'value': 'NSContactsUsageDescription', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + contactsStatements['moduleStatements'], + contactsStatements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add the contacts permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // Add the contacts usage description to the iOS Info.plist file + if (platforms.contains('ios')) { + updateIOSPermissions(iOSPermissions, arguments); + } + + print( + 'Contacts module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_deeplink.dart b/starter/scripts/modules/enable_deeplink.dart new file mode 100644 index 000000000..1b45b5c22 --- /dev/null +++ b/starter/scripts/modules/enable_deeplink.dart @@ -0,0 +1,147 @@ +import 'dart:io'; +import '../utils.dart'; +import '../utils/deeplink_utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + String? branchIOLiveKey = + getArgumentValue(arguments, 'branchIOLiveKey', required: true); + String? branchIOTestKey = + getArgumentValue(arguments, 'branchIOTestKey', required: true); + bool useTestKey = + getArgumentValue(arguments, 'branchIOUseTestKey')?.toLowerCase() == + 'true'; + String? scheme = + getArgumentValue(arguments, 'branchIOScheme', required: true); + List links = + getArgumentValue(arguments, 'branchIOLinks')?.split(',') ?? []; + + if (branchIOLiveKey == null || + branchIOLiveKey.isEmpty || + branchIOTestKey == null || + branchIOTestKey.isEmpty) { + print( + 'Error: Missing branchIOLiveKey argument. Usage: npm run useDeeplink branchIOLiveKey= branchIOTestKey= branchIOUseTestKey= branchIOScheme= branchIOLinks='); + exit(1); + } + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_deeplink/deferred_link_manager.dart';", + 'GetIt.I.registerSingleton(DeferredLinkManagerImpl());', + ], + 'useStatements': [ + 'static const useDeeplink = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_deeplink: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/deeplink''', + 'regex': + r'#\s*ensemble_deeplink:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/deeplink', + } + ]; + + // Prepare Branch.io initialization script for web + final branchScript = ''' +'''; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Inject the Branch.io script for the web platform + if (platforms.contains('web')) { + updateHtmlFile( + '', + branchScript, + ); + } + + updatePropertiesFile('branchTestKey', branchIOTestKey); + updatePropertiesFile('branchLiveKey', branchIOLiveKey); + + // Modify AndroidManifest.xml for deep linking + if (platforms.contains('android')) { + final branchMetaData = [ + '', + '', + '', + ]; + updateAndroidPermissions(metaData: branchMetaData); + updateAndroidManifestWithDeeplink( + scheme: scheme ?? '', + links: links, + ); + } + + // Modify Info.plist for deep linking on iOS + if (platforms.contains('ios')) { + addPermissionDescriptionToInfoPlist( + 'branch_universal_link_domains', + links, + isArray: true, + ); + + addPermissionDescriptionToInfoPlist( + 'branch_key', + { + 'live': branchIOLiveKey, + 'test': branchIOTestKey, + }, + isDict: true, + ); + + addBlockAboveLineInInfoPlist( + scheme ?? '', + '', + ); + + updateRunnerEntitlements( + module: 'deeplink', + deeplinkLinks: links, + ); + } + + print( + 'Deeplink module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_files.dart b/starter/scripts/modules/enable_files.dart new file mode 100644 index 000000000..48ca15de9 --- /dev/null +++ b/starter/scripts/modules/enable_files.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final fileManagerStatements = { + 'moduleStatements': [ + "import 'package:ensemble_file_manager/file_manager.dart';", + "GetIt.I.registerSingleton(FileManagerImpl());" + ], + 'useStatements': ["static const useFiles = true;"], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_file_manager: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/file_manager''', + 'regex': + r'#\s*ensemble_file_manager:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/file_manager', + } + ]; + + final androidPermissions = [ + '', + '', + ]; + + final iOSPermissions = [ + { + 'key': 'photoLibraryDescription', + 'value': 'NSPhotoLibraryUsageDescription', + }, + { + 'key': 'musicDescription', + 'value': 'NSAppleMusicUsageDescription', + } + ]; + + final iOSAdditionalSettings = [ + { + 'key': 'UIBackgroundModes', + 'value': ['fetch', 'remote-notification'], + 'isArray': true, + }, + { + 'key': 'UISupportsDocumentBrowser', + 'value': true, + 'isBoolean': true, + }, + { + 'key': 'LSSupportsOpeningDocumentsInPlace', + 'value': true, + 'isBoolean': true, + }, + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + fileManagerStatements['moduleStatements'], + fileManagerStatements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add the storage permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // Add the required keys and descriptions to the Info.plist file for iOS + if (platforms.contains('ios')) { + updateIOSPermissions( + iOSPermissions, + arguments, + additionalSettings: iOSAdditionalSettings, + ); + } + + // Success message + print( + 'File manager module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_firebase_analytics.dart b/starter/scripts/modules/enable_firebase_analytics.dart new file mode 100644 index 000000000..7e423734d --- /dev/null +++ b/starter/scripts/modules/enable_firebase_analytics.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import '../utils.dart'; +import '../utils/firebase_utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + String enableConsoleLogs = + getArgumentValue(arguments, 'enableConsoleLogs') ?? 'true'; + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_firebase_analytics/firebase_analytics.dart';", + "GetIt.I.registerSingleton(FirebaseAnalyticsProvider());", + "import 'dart:io';", + "import 'package:flutter/foundation.dart';", + ], + 'useStatements': [ + 'static const useFirebaseAnalytics = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_firebase_analytics: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/firebase_analytics''', + 'regex': + r'#\s*ensemble_firebase_analytics:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/firebase_analytics', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Generate Firebase configuration based on platform + updateFirebaseInitialization(platforms, arguments); + updateFirebaseConfig(platforms, arguments); + updateAnalyticsConfig(enableConsoleLogs); + + if (platforms.contains('android')) { + addClasspathDependency( + "classpath 'com.google.gms:google-services:4.3.15'"); + addPluginDependency("apply plugin: 'com.google.gms.google-services'"); + addImplementationDependency( + "implementation 'com.google.firebase:firebase-analytics'"); + addImplementationDependency( + "implementation platform('com.google.firebase:firebase-bom:32.7.0')"); + addSettingsPluginDependency( + 'id "com.google.gms.google-services" version "4.3.15" apply false'); + } + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + print( + 'Firebase Analytics module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_google_maps.dart b/starter/scripts/modules/enable_google_maps.dart new file mode 100644 index 000000000..1257e7710 --- /dev/null +++ b/starter/scripts/modules/enable_google_maps.dart @@ -0,0 +1,40 @@ +import 'dart:io'; +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + + String? googleMapsApiKeyAndroid = getArgumentValue( + arguments, 'androidGoogleMapsApiKey', + required: platforms.contains('android')); + String? googleMapsApiKeyIOS = getArgumentValue( + arguments, 'iOSGoogleMapsApiKey', + required: platforms.contains('ios')); + String? googleMapsApiKeyWeb = getArgumentValue( + arguments, + 'webGoogleMapsApiKey', + required: platforms.contains('web'), + ); + + try { + if (platforms.contains('android') && googleMapsApiKeyAndroid != null) { + updatePropertiesFile('googleMapsAPIKey', googleMapsApiKeyAndroid); + } + if (platforms.contains('ios') && googleMapsApiKeyIOS != null) { + updateAppDelegateForGoogleMaps(googleMapsApiKeyIOS); + } + + if (platforms.contains('web') && googleMapsApiKeyWeb != null) { + updateHtmlFile('', + '', + removalPattern: r'https://maps\.googleapis\.com/maps/api/js\?key=.*'); + } + + print( + 'Google Maps module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_location.dart b/starter/scripts/modules/enable_location.dart new file mode 100644 index 000000000..80bd4a279 --- /dev/null +++ b/starter/scripts/modules/enable_location.dart @@ -0,0 +1,77 @@ +import 'dart:io'; +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_location/location_module.dart';", + "GetIt.I.registerSingleton(LocationModuleImpl());", + ], + 'useStatements': [ + 'static const useLocation = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_location: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/location''', + 'regex': + r'#\s*ensemble_location:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/location', + } + ]; + + final androidPermissions = [ + '', + ]; + + final iOSPermissions = [ + { + 'key': 'inUseLocationDescription', + 'value': 'NSLocationWhenInUseUsageDescription', + }, + { + 'key': 'alwaysUseLocationDescription', + 'value': 'NSLocationAlwaysUsageDescription', + }, + { + 'key': 'locationDescription', + 'value': 'NSLocationAlwaysAndWhenInUseUsageDescription' + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add the location permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // Add the location usage description to the iOS Info.plist file + if (platforms.contains('ios')) { + updateIOSPermissions(iOSPermissions, arguments); + } + + print( + 'Location module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_moengage.dart b/starter/scripts/modules/enable_moengage.dart new file mode 100644 index 000000000..e8cd7fbe7 --- /dev/null +++ b/starter/scripts/modules/enable_moengage.dart @@ -0,0 +1,337 @@ +import 'dart:io'; +import '../utils.dart'; +import '../utils/firebase_utils.dart'; + +void main(List arguments) async { + try { + // Parse and validate arguments + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + String moengageAppId = + getArgumentValue(arguments, 'moengage_workspace_id', required: true) ?? + ''; + String enableConsoleLogs = + getArgumentValue(arguments, 'enableConsoleLogs') ?? 'true'; + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_moengage/moengage.dart';", + 'GetIt.I.registerSingleton(MoEngageImpl());', + "import 'dart:io';", + "import 'package:flutter/foundation.dart';", + ], + 'useStatements': [ + 'static const useMoEngage = true;', + ], + }; + + // Update Firebase configuration + updateFirebaseInitialization(platforms, arguments); + updateFirebaseConfig(platforms, arguments); + + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update pubspec.yaml + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_moengage: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/moengage''', + 'regex': + r'#\s*ensemble_moengage:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/moengage', + } + ]; + updatePubspec(pubspecDependencies); + + // Update MoEngage module registration with workspaceId and logs + String modulesContent = readFileContent(ensembleModulesFilePath); + modulesContent = modulesContent.replaceAll( + 'GetIt.I.registerSingleton(MoEngageImpl());', + 'GetIt.I.registerSingleton(MoEngageImpl(workspaceId: \'$moengageAppId\', enableLogs: $enableConsoleLogs));'); + writeFileContent(ensembleModulesFilePath, modulesContent); + + // Update ensemble.properties file + updatePropertiesFile("moengageWorkspaceId", moengageAppId); + + // Platform specific updates + if (platforms.contains('android')) { + await _updateAndroidConfiguration(arguments); + } + + if (platforms.contains('ios')) { + // Get all updates + final updates = [ + getMoEngageImportUpdate(), + getMoEngageInitUpdate(moengageAppId), + getMoEngageFunctionsUpdate(), + ]; + + updateAppDelegate(updates); + } + + print( + 'MoEngage module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} + +Future _updateAndroidConfiguration(List arguments) async { + final packageId = getPropertyValue('appId'); + final kotlinPath = getKotlinPath(packageId); + + // Create Kotlin files + await _createKotlinFiles(kotlinPath, packageId); + + // update gradle files + _updateGradleFile(); + + // Update AndroidManifest.xml + await modifyAndroidManifest(permissions: [ + '', + '', + '' + ], applicationAttributes: { + 'android:name': '.MyApplication' + }, intentFilters: [ + { + 'identifier': '', + 'content': ''' + + + + + + + ''' + } + ], services: [ + { + 'identifier': 'com.moengage.firebase.MoEFireBaseMessagingService', + 'content': ''' + + + + + ''' + } + ], activities: [ + { + 'identifier': 'com.moengage.pushbase.activities.PushTracker', + 'content': ''' +''' + } + ]); +} + +Future _createKotlinFiles(String kotlinPath, String packageId) async { + await createKotlinFile('$kotlinPath/CustomPushListener.kt', + _getCustomPushListenerContent(packageId)); + + await createKotlinFile( + '$kotlinPath/MyApplication.kt', _getMyApplicationContent(packageId)); + + await createKotlinFile( + '$kotlinPath/MainActivity.kt', _getMainActivityContent(packageId)); +} + +Future _updateGradleFile() async { + // Add MoEngage specific dependencies + addImplementationDependency( + "implementation platform('com.google.firebase:firebase-bom:32.7.0')"); + addImplementationDependency( + "implementation 'com.moengage:moe-android-sdk:12.8.01'"); + addImplementationDependency( + "implementation 'com.google.firebase:firebase-messaging:23.4.1'"); + addImplementationDependency( + "implementation 'androidx.lifecycle:lifecycle-process:2.7.0'"); + addImplementationDependency("implementation 'androidx.core:core:1.6.0'"); + addImplementationDependency( + "implementation 'androidx.appcompat:appcompat:1.3.1'"); + addImplementationDependency( + "implementation 'com.github.bumptech.glide:glide:4.9.0'"); +} + +// Kotlin file content templates +String _getCustomPushListenerContent(String packageId) => ''' +package $packageId + +import android.app.Activity +import android.os.Bundle +import com.moengage.core.internal.logger.Logger +import com.moengage.core.model.AccountMeta +import com.moengage.plugin.base.push.PluginPushCallback + +class CustomPushListener(accountMeta: AccountMeta) : PluginPushCallback(accountMeta) { + private val tag = "CustomPushListener" + + override fun onNotificationClick(activity: Activity, payload: Bundle): Boolean { + Logger.print { "\$tag onNotificationClick() : " } + return super.onNotificationClick(activity, payload) + } +}'''; + +String _getMyApplicationContent(String packageId) => ''' +package $packageId + +import com.moengage.core.DataCenter +import com.moengage.core.LogLevel +import com.moengage.core.MoEngage +import com.moengage.core.config.LogConfig +import com.moengage.core.config.FcmConfig +import com.moengage.core.config.MoEngageEnvironmentConfig +import com.moengage.core.config.NotificationConfig +import com.moengage.core.config.PushKitConfig +import com.moengage.core.model.AccountMeta +import com.moengage.core.model.SdkState +import com.moengage.core.model.environment.MoEngageEnvironment +import com.moengage.flutter.MoEInitializer +import com.moengage.pushbase.MoEPushHelper +import android.app.Application + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + val moEngage = MoEngage.Builder(this, BuildConfig.MOENGAGE_WORKSPACE_ID, DataCenter.DATA_CENTER_1) + .configureFcm(FcmConfig(true)) + .configurePushKit(PushKitConfig(true)) + .configureMoEngageEnvironment(MoEngageEnvironmentConfig(MoEngageEnvironment.DEFAULT)) + .configureNotificationMetaData( + NotificationConfig( + R.mipmap.ic_launcher, + R.mipmap.ic_launcher, + notificationColor = -1, + isMultipleNotificationInDrawerEnabled = false, + isBuildingBackStackEnabled = true, + isLargeIconDisplayEnabled = true + ) + ) + + MoEInitializer.initialiseDefaultInstance(this, moEngage) + } +}'''; + +String _getMainActivityContent(String packageId) => ''' +package $packageId + +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.util.Log +import com.moengage.flutter.MoEFlutterHelper +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterActivity() { + private val TAG = "MainActivity" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + processIntent(intent) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Log.d(TAG, "onConfigurationChanged(): \${newConfig.orientation}") + MoEFlutterHelper.getInstance().onConfigurationChanged() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processIntent(intent) + } + + private fun processIntent(intent: Intent?) { + if (intent == null) return + Log.d(TAG, "processIntent(): \${intent.data}") + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + } +}'''; + +// Get MoEngage import pattern updates +Map getMoEngageImportUpdate() { + return { + 'pattern': r'import\s+UIKit\s*\nimport\s+Flutter\s*\n', + 'replacement': '''import UIKit +import Flutter +import moengage_flutter_ios +import MoEngageSDK +import MoEngageInApps +import MoEngageMessaging + +''' + }; +} + +// Get MoEngage initialization pattern updates +Map getMoEngageInitUpdate(String moengageAppId) { + return { + 'pattern': + r'if\s+#available\(iOS\s+10\.0,\s*\*\)\s*{\s*\n\s*UNUserNotificationCenter\.current\(\)\.delegate\s*=\s*self\s+as\s+UNUserNotificationCenterDelegate\s*\n\s*}', + 'replacement': '''// MoEngage initialization + let sdkConfig = MoEngageSDKConfig(withAppID: "$moengageAppId") + sdkConfig.appGroupID = "group.com.alphadevs.MoEngage.NotificationServices" + sdkConfig.consoleLogConfig = MoEngageConsoleLogConfig(isLoggingEnabled: true, loglevel: .verbose) + + MoEngageSDKCore.sharedInstance.enableAllLogs() + MoEngageInitializer.sharedInstance.initializeDefaultInstance(sdkConfig, launchOptions: launchOptions) + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate + }''' + }; +} + +// Get MoEngage functions pattern updates +Map getMoEngageFunctionsUpdate() { + return { + 'pattern': + r'override\s+func\s+application\(\s*_\s+app:\s*UIApplication,\s*open\s+url:\s*URL,\s*options:\s*\[UIApplication\.OpenURLOptionsKey\s*:\s*Any\]\s*=\s*\[:\]\)\s*->\s*Bool\s*{\s*\n\s*//\s*Calling\s+flutter\s+method\s*"urlOpened"\s*from\s*iOS\s*\n\s*methodChannel\?\.invokeMethod\("urlOpened",\s*arguments:\s*url\.absoluteString\)\s*\n\s*return\s+true\s*\n\s*}\s*\n}', + 'replacement': + '''override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + // Calling flutter method "urlOpened" from iOS + methodChannel?.invokeMethod("urlOpened", arguments: url.absoluteString) + return true + } + + // MoEngage notification handling functions + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + MoEngageSDKMessaging.sharedInstance.setPushToken(deviceToken) + } + + override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .sound]) + } + + override func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + MoEngageSDKMessaging.sharedInstance.userNotificationCenter(center, didReceive: response) + completionHandler() + } + + override func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool { + print("Opening Universal link", userActivityType) + return false + } +}''' + }; +} \ No newline at end of file diff --git a/starter/scripts/modules/enable_network_info.dart b/starter/scripts/modules/enable_network_info.dart new file mode 100644 index 000000000..b2658912a --- /dev/null +++ b/starter/scripts/modules/enable_network_info.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + String? preciseLocationDescription = + getArgumentValue(arguments, 'preciseLocationDescription'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_network_info/network_info.dart';", + 'GetIt.I.registerSingleton(NetworkInfoImpl());', + ], + 'useStatements': [ + 'static const useNetworkInfo = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_network_info: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/ensemble_network_info''', + 'regex': + r'#\s*ensemble_network_info:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/ensemble_network_info', + } + ]; + + final androidPermissions = [ + '', + '' + ]; + + final iOSPermissions = [ + { + 'key': 'inUseLocationDescription', + 'value': 'NSLocationWhenInUseUsageDescription', + }, + { + 'key': 'alwaysUseLocationDescription', + 'value': 'NSLocationAlwaysAndWhenInUseUsageDescription', + }, + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + // Add required permissions to AndroidManifest.xml + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // Add required permissions to Info.plist + if (platforms.contains('ios')) { + if (preciseLocationDescription == null || + preciseLocationDescription.isEmpty) { + print("Error: Precise location description is missing."); + exit(1); + } + updateIOSPermissions(iOSPermissions, arguments); + addPermissionDescriptionToInfoPlist( + 'NSLocationTemporaryUsageDescriptionDictionary', + {'PreciseLocation': preciseLocationDescription}, + isDict: true, + ); + updateRunnerEntitlements(module: 'networkInfo'); + } + + print( + 'Network Info module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_notifications.dart b/starter/scripts/modules/enable_notifications.dart new file mode 100644 index 000000000..db2b6c80e --- /dev/null +++ b/starter/scripts/modules/enable_notifications.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import '../utils.dart'; +import '../utils/firebase_utils.dart'; + +void main(List arguments) { + List platforms = getPlatforms(arguments); + + final statements = { + 'moduleStatements': [ + "import 'package:flutter/foundation.dart';", + "import 'dart:io';", + ], + 'useStatements': [ + 'static const useNotifications = true;', + ], + }; + + final androidPermissions = [ + '', + ]; + + const notificationsMetaData = [ + '' + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Generate Firebase configuration based on platform + updateFirebaseInitialization(platforms, arguments); + updateFirebaseConfig(platforms, arguments); + + if (platforms.contains('android')) { + addClasspathDependency( + "classpath 'com.google.gms:google-services:4.3.15'"); + addPluginDependency("apply plugin: 'com.google.gms.google-services'"); + addSettingsPluginDependency( + 'id "com.google.gms.google-services" version "4.3.15" apply false'); + addImplementationDependency( + "implementation platform('com.google.firebase:firebase-bom:32.7.0')"); + } + + // Configure Android-specific settings + if (platforms.contains('android')) { + updateAndroidPermissions( + permissions: androidPermissions, metaData: notificationsMetaData); + } + + // Configure iOS-specific settings + if (platforms.contains('ios')) { + updateRunnerEntitlements( + module: 'notifications', + ); + } + + print( + 'Notifications module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/modules/enable_qr_code.dart b/starter/scripts/modules/enable_qr_code.dart new file mode 100644 index 000000000..553fd23d3 --- /dev/null +++ b/starter/scripts/modules/enable_qr_code.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import '../utils.dart'; + +Future main(List arguments) async { + List platforms = getPlatforms(arguments); + final cameraStatements = { + 'moduleStatements': [ + "import 'package:ensemble_camera/qr_code_scanner.dart';", + "GetIt.I.registerSingleton(", + " EnsembleQRCodeScannerImpl.build(EnsembleQRCodeScannerController()));", + ], + }; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + cameraStatements['moduleStatements'], + cameraStatements['useStatements'], + ); + + print('QR module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/scripts/utils.dart b/starter/scripts/utils.dart new file mode 100644 index 000000000..4c78e1276 --- /dev/null +++ b/starter/scripts/utils.dart @@ -0,0 +1,789 @@ +import 'dart:io'; + +const String ensembleModulesFilePath = 'lib/generated/ensemble_modules.dart'; +const String pubspecFilePath = 'pubspec.yaml'; +const String androidManifestFilePath = + 'android/app/src/main/AndroidManifest.xml'; +const String iosInfoPlistFilePath = 'ios/Runner/Info.plist'; +const String webIndexFilePath = 'web/index.html'; +const String ensemblePropertiesFilePath = 'ensemble/ensemble.properties'; +const String ensembleConfigFilePath = 'ensemble/ensemble-config.yaml'; +const String appDelegatePath = 'ios/Runner/AppDelegate.swift'; +const String runnerEntitlementsPath = 'ios/Runner/Runner.entitlements'; +const String androidBuildGradleFilePath = 'android/build.gradle'; +const String androidAppBuildGradleFilePath = 'android/app/build.gradle'; +const String androidSettingsGradleFilePath = 'android/settings.gradle'; +const String proguardRulesFilePath = 'android/app/proguard-rules.pro'; + +// To read file content +String readFileContent(String filePath) { + File file = File(filePath); + if (!file.existsSync()) { + throw Exception('$filePath not found.'); + } + return file.readAsStringSync(); +} + +// Helper function to parse individual arguments in key=value format +String? getArgumentValue(List arguments, String key, + {bool required = false}) { + for (var arg in arguments) { + final parts = arg.split('='); + if (parts.length == 2 && parts[0] == key) { + return parts[1]; + } + } + + if (required) { + throw Exception('Missing required argument: $key'); + } + + return null; +} + +// Process platforms argument, defaulting to ['ios', 'android', 'web'] if not specified +List getPlatforms(List arguments, + {List defaultPlatforms = const ['ios', 'android', 'web']}) { + String? platformArg = getArgumentValue(arguments, 'platform'); + if (platformArg != null && platformArg.isNotEmpty) { + return platformArg.split(',').map((platform) => platform.trim()).toList(); + } + return defaultPlatforms; +} + +// To update content using regex +String updateContent(String content, String regexPattern, String replacement) { + final RegExp regex = RegExp(regexPattern); + if (!regex.hasMatch(content) && !content.contains(replacement)) { + throw Exception('Pattern not found: $regexPattern'); + } + return content.replaceAllMapped(regex, (match) => replacement); +} + +// To write updated content to file +void writeFileContent(String filePath, String content) { + File file = File(filePath); + file.writeAsStringSync(content); +} + +// Add permission descriptions to Info.plist +void addPermissionDescriptionToInfoPlist(String key, dynamic description, + {bool isArray = false, bool isBoolean = false, bool isDict = false}) { + File plistFile = File(iosInfoPlistFilePath); + if (!plistFile.existsSync()) { + throw Exception('Error: File does not exist at $iosInfoPlistFilePath'); + } + + String plistContent = plistFile.readAsStringSync(); + bool updated = false; + + if (plistContent.contains('$key')) { + if (!isArray && !isBoolean && !isDict) { + RegExp regex = RegExp('$key\\s*[^<]*'); + String replacement = '$key\n $description'; + plistContent = plistContent.replaceAll(regex, replacement); + updated = true; + } else if (isBoolean) { + RegExp regex = RegExp('$key\\s*<(true|false)/>'); + String replacement = + '$key\n <${description ? 'true' : 'false'}/>'; + plistContent = plistContent.replaceAll(regex, replacement); + updated = true; + } else if (isArray) { + RegExp regex = + RegExp('$key\\s*(.*?)', dotAll: true); + String arrayValues = (description as List) + .map((item) => ' $item') + .join('\n'); + String replacement = + '$key\n \n$arrayValues\n '; + plistContent = plistContent.replaceAll(regex, replacement); + updated = true; + } else if (isDict) { + RegExp regex = + RegExp('$key\\s*(.*?)', dotAll: true); + String dictValues = (description as Map) + .entries + .map((entry) => + ' ${entry.key}\n ${entry.value}') + .join('\n'); + String replacement = + '$key\n \n$dictValues\n '; + plistContent = plistContent.replaceAll(regex, replacement); + updated = true; + } + } + + if (!updated) { + // Find the closing tag to insert before + final dictEndIndex = plistContent.lastIndexOf(''); + if (dictEndIndex != -1) { + String toInsert; + if (isArray) { + String arrayValues = (description as List) + .map((item) => ' $item') + .join('\n'); + toInsert = + ' $key\n \n$arrayValues\n \n'; + } else if (isBoolean) { + toInsert = + ' $key\n <${description ? 'true' : 'false'}/>\n'; + } else if (isDict) { + String dictValues = (description as Map) + .entries + .map((entry) => + ' ${entry.key}\n ${entry.value}') + .join('\n'); + toInsert = + ' $key\n \n$dictValues\n \n'; + } else { + toInsert = ' $key\n $description\n'; + } + + plistContent = + plistContent.replaceRange(dictEndIndex, dictEndIndex, toInsert); + updated = true; + } + } + + if (!updated) { + throw Exception('Failed to update Info.plist with $key'); + } + + plistFile.writeAsStringSync(plistContent); +} + +// Convert a string to a regex pattern +String toRegexPattern(String statement, {bool isBoolean = false}) { + if (isBoolean) { + // For boolean statements like 'static const useCamera = true;' + final prefix = statement.split('=')[0].trim(); + return RegExp.escape(prefix) + r'\s*=\s*(true|false);'; + } else { + // For code statements like imports or registrations that may be commented out + String escapedStatement = RegExp.escape(statement); + return r'\/\/\s*' + escapedStatement.replaceAll(' ', r'\s+'); + } +} + +// Update ensemble_modules.dart file +void updateEnsembleModules( + List? codeStatements, List? useStatements) { + String content = readFileContent(ensembleModulesFilePath); + + // Process code statements (imports and register statements) + if (codeStatements != null && codeStatements.isNotEmpty) { + for (var statement in codeStatements) { + String regexPattern = toRegexPattern(statement); + content = updateContent(content, regexPattern, statement); + } + } + + // Process use statements (e.g., static const useCamera = true) + if (useStatements != null && useStatements.isNotEmpty) { + for (var statement in useStatements) { + String regexPattern = toRegexPattern(statement, isBoolean: true); + content = updateContent(content, regexPattern, statement); + } + } + + writeFileContent(ensembleModulesFilePath, content); +} + +// Update pubspec.yaml file and throw error if content is not updated +void updatePubspec(List> pubspecDependencies) { + String pubspecContent = readFileContent(pubspecFilePath); + + for (var statementObj in pubspecDependencies) { + pubspecContent = updateContent(pubspecContent, statementObj['regex'] ?? '', + statementObj['statement'] ?? ''); + } + + writeFileContent(pubspecFilePath, pubspecContent); +} + +// Update AndroidManifest.xml with permissions and throw error if not updated +void updateAndroidPermissions( + {List? permissions, List? metaData}) { + String manifestContent = readFileContent(androidManifestFilePath); + + if (permissions != null && permissions.isNotEmpty) { + String comment = + ''; + + for (var permission in permissions) { + if (!manifestContent.contains(permission)) { + manifestContent = manifestContent.replaceFirst( + comment, + '$comment\n $permission', + ); + } + } + } + + // Handle meta-data if provided + if (metaData != null && metaData.isNotEmpty) { + final applicationEndIndex = manifestContent.lastIndexOf(''); + if (applicationEndIndex == -1) { + throw Exception( + 'Error: Could not find tag in AndroidManifest.xml'); + } + + for (String metaDataContent in metaData) { + if (!manifestContent.contains(metaDataContent)) { + manifestContent = manifestContent.replaceRange(applicationEndIndex, + applicationEndIndex, ' $metaDataContent\n '); + } + } + } + + writeFileContent(androidManifestFilePath, manifestContent); +} + +void updateMainActivity(Map>> updates) { + const baseDir = 'android/app/src/main'; + + String? activityFilePath = findMainActivity(baseDir); + if (activityFilePath == null) { + throw Exception('MainActivity not found in $baseDir'); + } + + String content = readFileContent(activityFilePath); + bool isKotlin = activityFilePath.endsWith('.kt'); + + try { + final patterns = isKotlin ? updates['kotlin']! : updates['java']!; + bool requiresUpdate = false; + String updatedContent = content; + + // Check if any of the desired states already exist + for (final update in patterns) { + final desiredContent = update['replacement'] ?? ''; + if (!content.contains(desiredContent)) { + requiresUpdate = true; + break; + } + } + + // Apply updates if needed + if (requiresUpdate) { + for (final update in patterns) { + try { + final newContent = updateContent(updatedContent, + update['pattern'] ?? '', update['replacement'] ?? ''); + if (newContent != updatedContent) { + updatedContent = newContent; + } + } catch (_) { + // Continue with next pattern if one fails + continue; + } + } + + // Write changes if content was modified + if (updatedContent != content) { + writeFileContent(activityFilePath, updatedContent); + } + } + } catch (e) { + throw Exception('Failed to update MainActivity: $e'); + } +} + +/// Finds the MainActivity file in the project +String? findMainActivity(String baseDir) { + final commonPaths = ['kotlin', 'java']; + + for (final path in commonPaths) { + final dir = Directory('$baseDir/$path'); + if (!dir.existsSync()) continue; + + try { + final files = dir.listSync(recursive: true); + final activityFile = files.firstWhere( + (file) => + file.path.endsWith('MainActivity.kt') || + file.path.endsWith('MainActivity.java'), + orElse: () => File(''), + ); + + if (activityFile.path.isNotEmpty) { + return activityFile.path; + } + } catch (_) { + continue; + } + } + + return null; +} + +// Update Info.plist for iOS with permissions and descriptions +void updateIOSPermissions( + List> iOSPermissions, List arguments, + {List> additionalSettings = const []}) { + for (var permission in iOSPermissions) { + String? paramValue = getArgumentValue(arguments, permission['key']!); + + if (paramValue != null && paramValue.isNotEmpty) { + addPermissionDescriptionToInfoPlist( + permission['value'] ?? '', paramValue); + } + } + + // Process additional settings (arrays, booleans, etc.) if provided + for (var setting in additionalSettings) { + if (setting['isArray'] == true) { + addPermissionDescriptionToInfoPlist(setting['key'], setting['value'], + isArray: true); + } else if (setting['isBoolean'] == true) { + addPermissionDescriptionToInfoPlist(setting['key'], setting['value'], + isBoolean: true); + } else { + addPermissionDescriptionToInfoPlist(setting['key'], setting['value']); + } + } +} + +// To update an HTML file with a new content before a specific marker (like ) +void updateHtmlFile(String marker, String contentToAdd, + {String? removalPattern}) { + if (!File(webIndexFilePath).existsSync()) { + throw Exception('Error: $webIndexFilePath not found'); + } + + String content = File(webIndexFilePath).readAsStringSync(); + + // Remove existing tag + if (removalPattern != null) { + content = removeExistingTag(content, removalPattern); + } + + if (!content.contains(contentToAdd)) { + content = content.replaceFirst(marker, ' $contentToAdd\n$marker'); + File(webIndexFilePath).writeAsStringSync(content); + } +} + +String removeExistingTag(String content, String pattern) { + final regex = RegExp(pattern); + final lines = content.split('\n'); + final filteredLines = lines.where((line) => !regex.hasMatch(line)).toList(); + return filteredLines.join('\n'); +} + +void updatePropertiesFile(String key, String value) { + File propertiesFile = File(ensemblePropertiesFilePath); + if (!propertiesFile.existsSync()) { + throw Exception('Error: $ensemblePropertiesFilePath not found.'); + } + + List lines = propertiesFile.readAsLinesSync(); + bool updated = false; + + for (int i = 0; i < lines.length; i++) { + if (lines[i].startsWith('$key=')) { + lines[i] = '$key=$value'; + updated = true; + break; + } + } + + if (!updated) { + lines.add('$key=$value'); + } + + propertiesFile.writeAsStringSync(lines.join('\n').trim()); +} + +void updateAppDelegateForGoogleMaps(String googleMapsApiKey) { + File appDelegateFile = File(appDelegatePath); + if (!appDelegateFile.existsSync()) { + throw Exception('Error: $appDelegatePath not found.'); + } + + // Read the file content + String content = appDelegateFile.readAsStringSync(); + + // Uncomment the Google Maps import and API key lines if they are commented + content = content.replaceAllMapped( + RegExp(r'\/\/\s*import\s+GoogleMaps'), (match) => 'import GoogleMaps'); + + content = content.replaceAllMapped( + RegExp(r'\/\/\s*GMSServices\.provideAPIKey\("(.*?)"\)'), + (match) => ' GMSServices.provideAPIKey("$googleMapsApiKey")'); + + // Write the updated content back to the file + appDelegateFile.writeAsStringSync(content.trim()); + print('AppDelegate.swift updated successfully with Google Maps API key.'); +} + +extension StringExtensions on String { + String capitalize() { + return this.isEmpty + ? this + : this[0].toUpperCase() + this.substring(1).toLowerCase(); + } +} + +// Function to update the Runner.entitlements file with the given keys and values. +void updateRunnerEntitlements({ + String module = 'deeplink', + List? deeplinkLinks, +}) { + File entitlementsFile = File(runnerEntitlementsPath); + if (!entitlementsFile.existsSync()) { + throw Exception( + 'Error: Runner.entitlements file does not exist at $runnerEntitlementsPath'); + } + + String entitlementsContent = entitlementsFile.readAsStringSync(); + + if (module == 'deeplink') { + String deeplinkEntries = deeplinkLinks! + .map((link) => ' applinks:$link') + .join('\n'); + + if (entitlementsContent + .contains('com.apple.developer.associated-domains')) { + entitlementsContent = entitlementsContent.replaceFirst( + RegExp( + 'com.apple.developer.associated-domains\\s*.*?', + dotAll: true), + ''' +com.apple.developer.associated-domains + +$deeplinkEntries + '''); + + entitlementsFile.writeAsStringSync(entitlementsContent); + } else { + print( + 'No com.apple.developer.associated-domains block found in Runner.entitlements.'); + } + } else if (module == 'notifications') { + if (entitlementsContent.contains('development')) { + // replace the existing value with 'production' + entitlementsContent = entitlementsContent.replaceFirst( + 'development', 'production'); + entitlementsFile.writeAsStringSync(entitlementsContent); + } else { + print( + 'No aps-environment block found in Runner.entitlements.'); + } + } else if (module == 'networkInfo') { + if (!entitlementsContent.contains( + 'com.apple.security.personal-information.location')) { + entitlementsContent = entitlementsContent.replaceFirst('', ''' + com.apple.security.personal-information.location + + com.apple.developer.networking.wifi-info + +'''); + entitlementsFile.writeAsStringSync(entitlementsContent); + } + } +} + +// Reads a specific key from ensemble.properties +/// [key] The property key to look for (e.g., 'appId') +/// [defaultValue] Optional fallback value if key isn't found +/// Returns the value associated with the key or throws if not found +String getPropertyValue(String key, {String? defaultValue}) { + try { + // Read the properties file synchronously + final properties = File(ensemblePropertiesFilePath).readAsStringSync(); + + // Look for a line that matches key=value pattern + final match = RegExp('$key=(.+)').firstMatch(properties); + + // If match found, return group 1 (the value part) + // If no match and defaultValue provided, return defaultValue + // Otherwise throw exception + if (match != null) { + return match.group(1)!; + } else if (defaultValue != null) { + return defaultValue; + } else { + throw Exception('$key not found in properties file'); + } + } catch (e) { + throw Exception('Error reading $key: $e'); + } +} + +/// Gets the Kotlin source path for a package +String getKotlinPath(String packageId) { + return 'android/app/src/main/kotlin/${packageId.split('.').join('/')}'; +} + +/// Creates or updates a Kotlin file +Future createKotlinFile(String path, String content) async { + final file = File(path); + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsString(content); +} + +/// Enhanced Android Manifest modification function +Future modifyAndroidManifest({ + List? permissions, + Map? applicationAttributes, + List>? intentFilters, + List>? services, + List>? activities, + String? launchMode, +}) async { + String content = readFileContent(androidManifestFilePath); + + // Add tools namespace if missing + if (!content.contains('xmlns:tools')) { + content = content.replaceFirst( + ' content.contains(permission))) { + content = content.replaceFirst( + '', ' $permissionsBlock\n'); + } + } + + // Update application attributes + if (applicationAttributes != null) { + final applicationPattern = RegExp(r']*>'); + content = content.replaceAllMapped(applicationPattern, (match) { + String appTag = match.group(0) ?? ''; + applicationAttributes.forEach((key, value) { + if (appTag.contains('$key=')) { + appTag = appTag.replaceAll(RegExp('$key="[^"]*"'), '$key="$value"'); + } else { + appTag = appTag.replaceAll('>', ' $key="$value">'); + } + }); + return appTag; + }); + } + + // Update launch mode if provided + if (launchMode != null) { + content = content.replaceFirst(RegExp(r'android:launchMode="[^"]*"'), + 'android:launchMode="$launchMode"'); + } + + // Add intent filters + if (intentFilters != null) { + for (final filter in intentFilters) { + if (!content.contains(filter['identifier'] as String)) { + content = content.replaceFirst( + '', '${filter['content']}\n '); + } + } + } + + // Add services + if (services != null) { + final applicationEndIndex = content.lastIndexOf(''); + if (applicationEndIndex != -1) { + final servicesBlock = services + .where( + (service) => !content.contains(service['identifier'] as String)) + .map((service) => service['content']) + .join('\n\n '); + + if (servicesBlock.isNotEmpty) { + content = content.replaceRange(applicationEndIndex, applicationEndIndex, + ' $servicesBlock\n '); + } + } + } + + // Add activities + if (activities != null) { + final applicationEndIndex = content.lastIndexOf(''); + if (applicationEndIndex != -1) { + final activitiesBlock = activities + .where( + (activity) => !content.contains(activity['identifier'] as String)) + .map((activity) => activity['content']) + .join('\n\n '); + + if (activitiesBlock.isNotEmpty) { + content = content.replaceRange(applicationEndIndex, applicationEndIndex, + ' $activitiesBlock\n '); + } + } + } + + writeFileContent(androidManifestFilePath, content); +} + +// Utility function to normalize line endings +String normalizeLineEndings(String content) { + return content.replaceAll('\r\n', '\n'); +} + +/// Updates the AppDelegate.swift file with the provided updates. +/// +/// [updates] is a list of maps. Each map should contain: +/// - 'pattern': The text pattern to find in AppDelegate.swift +/// - 'replacement': The text to replace the pattern with +void updateAppDelegate(List> updates) { + String appDelegateFilePath = appDelegatePath; + if (!File(appDelegateFilePath).existsSync()) { + throw Exception('AppDelegate.swift not found at $appDelegateFilePath'); + } + + String appDelegateContent = readFileContent(appDelegateFilePath); + // normalizing the appDelegate content to remove extra spaces. + appDelegateContent = normalizeLineEndings(appDelegateContent); + + try { + bool requiresUpdate = false; + String updatedContent = appDelegateContent; + + // Check if any updates are needed + for (final update in updates) { + final desiredContent = update['replacement'] ?? ''; + if (!appDelegateContent.contains(desiredContent)) { + requiresUpdate = true; + break; + } + } + + if (requiresUpdate) { + for (final update in updates) { + try { + final newContent = updateContent( + updatedContent, + update['pattern'] ?? '', + update['replacement'] ?? '', + ); + if (newContent != updatedContent) { + updatedContent = newContent; + } + } catch (e) { + print('Failed to apply update: $e'); + continue; + } + } + + if (updatedContent != appDelegateContent) { + writeFileContent(appDelegateFilePath, updatedContent); + } + } else { + print('No updates needed'); + } + } catch (e) { + throw Exception('Failed to update AppDelegate.swift: $e'); + } +} + +/// Retrieves the ensemble version. +/// +/// If [version] is provided and different from the current ensemble version, +/// it updates the `pubspec.yaml` with the new version. +/// Otherwise, it returns the existing version or defaults to 'main'. +/// +/// Returns the effective ensemble version as a [String]. +Future packageVersion({String? version}) async { + try { + final current = await getEnsembleVersion(); + if (version != null && + version.trim().isNotEmpty && + version.trim() != current) { + return await updateEnsembleVersion(version.trim()) + ? version.trim() + : current; + } + return current; + } catch (e) { + print('Error: $e'); + return 'main'; + } +} + +/// Reads the pubspec.yaml file and returns the 'ref' of the 'ensemble' package. +/// Returns 'main' if the 'ref' is not found or the 'ensemble' package is not a git dependency. +Future getEnsembleVersion() async { + final file = File(pubspecFilePath); + if (!await file.exists()) return 'main'; + + try { + final lines = await file.readAsLines(); + final refInfo = _findEnsembleGitRef(lines); + return refInfo['ref']?.isNotEmpty == true ? refInfo['ref'] : 'main'; + } catch (e) { + print('Error reading ensemble version: $e'); + return 'main'; + } +} + +/// Updates the 'ref' value of the 'ensemble' package in pubspec.yaml. +/// +/// [newVersion] - The new version to set for the 'ensemble' package. +/// +/// Returns `true` if the update was successful, `false` otherwise. +Future updateEnsembleVersion(String newVersion) async { + final file = File(pubspecFilePath); + + try { + final lines = await file.readAsLines(); + final refInfo = _findEnsembleGitRef(lines); + + if (refInfo['index'] != null) { + final indentation = refInfo['indentation'] ?? ''; + lines[refInfo['index']] = '${indentation}ref: $newVersion'; + await file.writeAsString(lines.join('\n')); + return true; + } else { + print("'ref:' not found under 'ensemble' git dependency."); + return false; + } + } catch (e) { + print('Error updating ensemble version: $e'); + return false; + } +} + +/// Helper function to locate the 'ref' line within the 'ensemble' git dependency. +/// +/// Returns a [Map] containing: +/// - 'ref': The current ref value (if found). +/// - 'index': The line index of the 'ref:' key (if found). +/// - 'indentation': The indentation before the 'ref:' key (if found). +Map _findEnsembleGitRef(List lines) { + bool inEnsemble = false; + bool inGit = false; + + for (int i = 0; i < lines.length; i++) { + final trimmed = lines[i].trim(); + if (trimmed.startsWith('ensemble:')) { + inEnsemble = true; + inGit = false; + continue; + } + if (inEnsemble) { + if (trimmed.startsWith('git:')) { + inGit = true; + continue; + } + if (inGit && trimmed.startsWith('ref:')) { + final ref = trimmed.split(':').last.trim(); + final indentation = lines[i].substring(0, lines[i].indexOf('ref:')); + return {'ref': ref, 'index': i, 'indentation': indentation}; + } + // If another dependency starts, exit the ensemble block + if (trimmed.endsWith(':') && + !trimmed.startsWith('git:') && + !trimmed.startsWith('ref:')) { + break; + } + } + } + return {}; +} diff --git a/starter/scripts/utils/deeplink_utils.dart b/starter/scripts/utils/deeplink_utils.dart new file mode 100644 index 000000000..1d0a986fb --- /dev/null +++ b/starter/scripts/utils/deeplink_utils.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import '../utils.dart'; + +// Function to update AndroidManifest.xml with deep link and Branch configuration. +void updateAndroidManifestWithDeeplink({ + required String scheme, + required List links, +}) { + String manifestContent = readFileContent(androidManifestFilePath); + + // Update the launchMode for the main activity from "singleTop" to "singleTask" + manifestContent = manifestContent.replaceFirst( + 'android:launchMode="singleTop"', + 'android:launchMode="singleTask"', + ); + + // Add the Branch URI scheme and App Links inside the MainActivity + final branchURIScheme = ''' + + + + + + + + '''; + + final branchAppLinks = ''' + + + + + + ${links.map((link) { + final parts = link.split("://"); + final scheme = parts.length > 1 ? parts[0] : 'https'; + final host = parts[parts.length - 1].replaceAll('/', ''); + return ''; + }).join("\n ")} + '''; + + // Insert the Branch-related intent filters inside the tag for MainActivity + if (!manifestContent.contains('')) { + manifestContent = manifestContent.replaceFirst( + '', + '$branchURIScheme\n$branchAppLinks\n ', + ); + } + // Write the modified content back to the file + writeFileContent(androidManifestFilePath, manifestContent); +} + +// Function to add a block of code above a specific line in Info.plist +void addBlockAboveLineInInfoPlist(String scheme, String lineToFind) { + File plistFile = File(iosInfoPlistFilePath); + if (!plistFile.existsSync()) { + throw Exception('Error: File does not exist at $iosInfoPlistFilePath'); + } + + String plistContent = plistFile.readAsStringSync(); + + // Define the block to insert, now using the passed `scheme` + final blockToInsert = ''' + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + $scheme + + CFBundleURLName + \$(PRODUCT_BUNDLE_IDENTIFIER) + +'''; + + // Insert the block above the specified line + if (!plistContent.contains(blockToInsert)) { + int insertIndex = plistContent.indexOf(lineToFind); + if (insertIndex != -1) { + plistContent = plistContent.replaceRange( + insertIndex, insertIndex, '$blockToInsert\n '); + plistFile.writeAsStringSync(plistContent); + print('Block added above $lineToFind in Info.plist'); + } else { + throw Exception( + 'Error: The line "$lineToFind" was not found in Info.plist.'); + } + } else { + print('Block already exists in Info.plist, skipping.'); + } +} diff --git a/starter/scripts/utils/firebase_utils.dart b/starter/scripts/utils/firebase_utils.dart new file mode 100644 index 000000000..3a97ab9d8 --- /dev/null +++ b/starter/scripts/utils/firebase_utils.dart @@ -0,0 +1,299 @@ +import 'dart:io'; + +import '../utils.dart'; + +void updateFirebaseInitialization( + List platforms, List arguments) { + // Get Firebase configuration values + String? androidApiKey = getArgumentValue(arguments, 'android_apiKey', + required: platforms.contains('android')); + String? androidAppId = getArgumentValue(arguments, 'android_appId', + required: platforms.contains('android')); + String? androidMessagingSenderId = getArgumentValue( + arguments, 'android_messagingSenderId', + required: platforms.contains('android')); + String? androidProjectId = getArgumentValue(arguments, 'android_projectId', + required: platforms.contains('android')); + + String? iosApiKey = getArgumentValue(arguments, 'ios_apiKey', + required: platforms.contains('ios')); + String? iosAppId = getArgumentValue(arguments, 'ios_appId', + required: platforms.contains('ios')); + String? iosMessagingSenderId = getArgumentValue( + arguments, 'ios_messagingSenderId', + required: platforms.contains('ios')); + String? iosProjectId = getArgumentValue(arguments, 'ios_projectId', + required: platforms.contains('ios')); + + String? webApiKey = getArgumentValue(arguments, 'web_apiKey', + required: platforms.contains('web')); + String? webAppId = getArgumentValue(arguments, 'web_appId', + required: platforms.contains('web')); + String? webAuthDomain = getArgumentValue(arguments, 'web_authDomain', + required: platforms.contains('web')); + String? webMessagingSenderId = getArgumentValue( + arguments, 'web_messagingSenderId', + required: platforms.contains('web')); + String? webProjectId = getArgumentValue(arguments, 'web_projectId', + required: platforms.contains('web')); + String? webStorageBucket = getArgumentValue(arguments, 'web_storageBucket', + required: platforms.contains('web')); + String? webMeasurementId = getArgumentValue(arguments, 'web_measurementId', + required: platforms.contains('web')); + + final buffer = StringBuffer(); + buffer.writeln('FirebaseOptions? androidPayload;'); + buffer.writeln(' FirebaseOptions? iosPayload;'); + buffer.writeln(' FirebaseOptions? webPayload;'); + + if (platforms.contains('android')) { + buffer.writeln(' androidPayload = const FirebaseOptions('); + buffer.writeln(' apiKey: "$androidApiKey",'); + buffer.writeln(' appId: "$androidAppId",'); + buffer.writeln(' messagingSenderId: "$androidMessagingSenderId",'); + buffer.writeln(' projectId: "$androidProjectId",'); + buffer.writeln(' );'); + } + + if (platforms.contains('ios')) { + buffer.writeln(' iosPayload = const FirebaseOptions('); + buffer.writeln(' apiKey: "$iosApiKey",'); + buffer.writeln(' appId: "$iosAppId",'); + buffer.writeln(' messagingSenderId: "$iosMessagingSenderId",'); + buffer.writeln(' projectId: "$iosProjectId",'); + buffer.writeln(' );'); + } + + if (platforms.contains('web')) { + buffer.writeln(' webPayload = const FirebaseOptions('); + buffer.writeln(' apiKey: "$webApiKey",'); + buffer.writeln(' appId: "$webAppId",'); + buffer.writeln(' authDomain: "$webAuthDomain",'); + buffer.writeln(' messagingSenderId: "$webMessagingSenderId",'); + buffer.writeln(' projectId: "$webProjectId",'); + buffer.writeln(' storageBucket: "$webStorageBucket",'); + buffer.writeln(' measurementId: "$webMeasurementId",'); + buffer.writeln(' );'); + } + + buffer.writeln(' FirebaseOptions? selectedPayload;'); + buffer.writeln(' if (Platform.isAndroid) {'); + buffer.writeln(' selectedPayload = androidPayload;'); + buffer.writeln(' } else if (Platform.isIOS) {'); + buffer.writeln(' selectedPayload = iosPayload;'); + buffer.writeln(' }'); + buffer.writeln(' if (kIsWeb) {'); + buffer.writeln(' selectedPayload = webPayload;'); + buffer.writeln(' }'); + buffer.writeln( + ' await Firebase.initializeApp(options: selectedPayload);'); + + String newCode = buffer.toString().trim(); + + // Now replace the Firebase initialization code in the file + final File file = File(ensembleModulesFilePath); + String content = file.readAsStringSync(); + + // Regular expression to match the current Firebase initialization block + final regex = RegExp( + r'await\s*Firebase\.initializeApp\(\);', + dotAll: true, + ); + + // Replace the existing Firebase initialization block with the new code + if (regex.hasMatch(content)) { + content = content.replaceFirst(regex, newCode); + } + + file.writeAsStringSync(content); +} + +void updateAnalyticsConfig( + String enableConsoleLogs, { + String provider = 'firebase', +}) { + try { + final file = File(ensembleConfigFilePath); + if (!file.existsSync()) { + throw Exception('Config file not found.'); + } + + String content = file.readAsStringSync(); + + // Replace the analytics block + content = content.replaceAllMapped( + RegExp( + r'#\s*analytics:\s*\n#\s*provider:\s*firebase\s*\n#\s*enabled:\s*true\s*\n#\s*enableConsoleLogs:\s*true', + multiLine: true), + (match) => + 'analytics:\n provider: $provider\n enabled: true\n enableConsoleLogs: $enableConsoleLogs', + ); + + // Write the updated content back to the file + file.writeAsStringSync(content); + print('ensemble-config.yaml updated successfully.'); + } catch (e) { + throw Exception('Failed to update ensemble-config.yaml: $e'); + } +} + +Map getFirebaseKeys(String platform, List arguments) { + const keyPrefixes = { + 'web': 'web_', + 'android': 'android_', + 'ios': 'ios_', + }; + + final prefix = keyPrefixes[platform] ?? ''; + return { + 'apiKey': getArgumentValue(arguments, '${prefix}apiKey') ?? '', + 'authDomain': getArgumentValue(arguments, '${prefix}authDomain') ?? '', + 'projectId': getArgumentValue(arguments, '${prefix}projectId') ?? '', + 'storageBucket': + getArgumentValue(arguments, '${prefix}storageBucket') ?? '', + 'messagingSenderId': + getArgumentValue(arguments, '${prefix}messagingSenderId') ?? '', + 'appId': getArgumentValue(arguments, '${prefix}appId') ?? '', + 'measurementId': + getArgumentValue(arguments, '${prefix}measurementId') ?? '', + }; +} + +void updateFirebaseConfig(List platforms, List arguments) { + final file = File(ensembleConfigFilePath); + if (!file.existsSync()) { + throw Exception('Config file not found.'); + } + + final platform = platforms.first; + final keys = getFirebaseKeys(platform, arguments); + + String content = file.readAsStringSync(); + + // Update only the firebase:$platform section + content = content.replaceAllMapped( + RegExp(r'#\s*firebase:\s*\n\s*#\s*web:', multiLine: true), + (match) => ' firebase:\n $platform:', + ); + + // Uncomment the Firebase accounts structure + final accountLines = [ + '#\\s*accounts:', + '#\\s*firebase:', + '#\\s*$platform:', + ]; + + for (final line in accountLines) { + content = content.replaceAllMapped(RegExp(line, multiLine: true), (match) { + return match[0]!.replaceFirst('#', ''); + }); + } + + // Replace the placeholders with actual keys only within the $platform block + keys.forEach((key, value) { + if (value.isNotEmpty) { + content = content.replaceAllMapped( + RegExp( + r'(firebase:\s*\n.*?' + + platform + + r':\s*\n.*?)(\b' + + key + + r':\s*).*?(\n|$)', + multiLine: true, + dotAll: true, + ), + (match) => '${match.group(1)}$key: "$value"${match.group(3)}', + ); + content = content.replaceAllMapped( + RegExp(r'#\s*' + key + r':\s*".*"', multiLine: true), + (match) => match.group(0)!.replaceFirst('#', ''), + ); + } + }); + + file.writeAsStringSync(content); +} + +void addClasspathDependency(String dependency) { + final file = File(androidBuildGradleFilePath); + if (!file.existsSync()) { + throw Exception('Android build file not found.'); + } + + String content = file.readAsStringSync(); + + if (!content.contains(dependency)) { + final buildscriptRegExp = + RegExp(r'buildscript\s*{[\s\S]*?dependencies\s*{'); + final match = buildscriptRegExp.firstMatch(content); + if (match != null) { + final insertPosition = match.end; + content = content.replaceRange( + insertPosition, insertPosition, '\n $dependency'); + } + } + + // Save the updated content back to the file + file.writeAsStringSync(content); +} + +void addPluginDependency(String dependency) { + final file = File(androidAppBuildGradleFilePath); + if (!file.existsSync()) { + throw Exception('Android app build file not found.'); + } + + String content = file.readAsStringSync(); + + // Add the plugin dependency if it doesn't already exist + if (!content.contains(dependency)) { + content = content.replaceFirst( + RegExp(r"apply\s*plugin:\s*'com\.android\.application'"), + 'apply plugin: \'com.android.application\'\n$dependency', + ); + } + + file.writeAsStringSync(content); +} + +void addImplementationDependency(String dependency) { + final file = File(androidAppBuildGradleFilePath); + if (!file.existsSync()) { + throw Exception('Android app build file not found.'); + } + + String content = file.readAsStringSync(); + + // Add the implementation dependency if it doesn't already exist + if (!content.contains(dependency)) { + final dependenciesRegExp = RegExp(r'dependencies\s*{'); + final match = dependenciesRegExp.firstMatch(content); + if (match != null) { + final insertPosition = match.end; + content = content.replaceRange( + insertPosition, insertPosition, '\n $dependency'); + } + } + + file.writeAsStringSync(content); +} + +void addSettingsPluginDependency(String dependency) { + final file = File(androidSettingsGradleFilePath); + if (!file.existsSync()) { + throw Exception('Android settings file not found.'); + } + + String content = file.readAsStringSync(); + + // Check if the dependency is already included in the plugins block + if (!content.contains(dependency)) { + int insertPosition = content.indexOf('plugins {'); + int endPosition = content.indexOf('}', insertPosition); + content = content.substring(0, endPosition) + + '\n $dependency\n' + + content.substring(endPosition); + + file.writeAsStringSync(content); + } +} diff --git a/starter/scripts/utils/proguard_utils.dart b/starter/scripts/utils/proguard_utils.dart new file mode 100644 index 000000000..67489277f --- /dev/null +++ b/starter/scripts/utils/proguard_utils.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import '../utils.dart'; + +void createProguardRules(String rules) { + try { + final file = File(proguardRulesFilePath); + if (!file.existsSync()) { + file.createSync(recursive: true); + } + + final content = file.readAsStringSync(); + + if (!content.contains(rules) && rules.isNotEmpty) { + file.writeAsStringSync('$content\n$rules'); + updateBuildGradleProguardFiles(); + } + } catch (e) { + throw Exception( + '❌ Starter Error: Failed to create proguard-rules.pro file: $e'); + } +} + +void updateBuildGradleProguardFiles() { + try { + final file = File(androidAppBuildGradleFilePath); + if (!file.existsSync()) { + throw Exception('build.gradle file not found.'); + } + + String content = file.readAsStringSync(); + + // Update the proguardFiles in the build.gradle file + if (!content.contains('proguardFiles')) { + content = content.replaceAllMapped( + RegExp(r'buildTypes\s*{[^}]*release\s*{', multiLine: true), + (match) => + "buildTypes {\n release {\n proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'"); + } + + file.writeAsStringSync(content); + } catch (e) { + throw Exception('❌ Starter Error: Failed to update build.gradle: $e'); + } +} diff --git a/starter/src/common-params.ts b/starter/src/common-params.ts new file mode 100644 index 000000000..1fd2ca52c --- /dev/null +++ b/starter/src/common-params.ts @@ -0,0 +1,124 @@ +import { Parameter } from './interfaces'; + +export const firebaseAndroidParameters: Parameter[] = [ + { + key: 'android_apiKey', + question: 'Please provide your Firebase **Android** API key:', + platform: ['android'], + type: 'text', + }, + { + key: 'android_appId', + question: 'Please provide your Firebase **Android** App ID:', + platform: ['android'], + type: 'text', + }, + { + key: 'android_messagingSenderId', + question: 'Please provide your Firebase **Android** Messaging Sender ID:', + platform: ['android'], + type: 'text', + }, + { + key: 'android_projectId', + question: 'Please provide your Firebase **Android** Project ID:', + platform: ['android'], + type: 'text', + }, + { + key: 'android_storageBucket', + question: 'Please provide your Firebase **Android** Storage Bucket:', + platform: ['android'], + type: 'text', + }, + { + key: 'android_authDomain', + question: 'Please provide your Firebase **Android** Auth Domain:', + platform: ['android'], + type: 'text', + }, +]; + +export const firebaseIOSParameters: Parameter[] = [ + { + key: 'ios_apiKey', + question: 'Please provide your Firebase **iOS** API key:', + platform: ['ios'], + type: 'text', + }, + { + key: 'ios_appId', + question: 'Please provide your Firebase **iOS** App ID:', + platform: ['ios'], + type: 'text', + }, + { + key: 'ios_messagingSenderId', + question: 'Please provide your Firebase **iOS** Messaging Sender ID:', + platform: ['ios'], + type: 'text', + }, + { + key: 'ios_projectId', + question: 'Please provide your Firebase **iOS** Project ID:', + platform: ['ios'], + type: 'text', + }, + { + key: 'ios_storageBucket', + question: 'Please provide your Firebase **iOS** Storage Bucket:', + platform: ['ios'], + type: 'text', + }, + { + key: 'ios_authDomain', + question: 'Please provide your Firebase **iOS** Auth Domain:', + platform: ['ios'], + type: 'text', + }, +]; + +export const firebaseWebParameters: Parameter[] = [ + { + key: 'web_apiKey', + question: 'Please provide your Firebase **Web** API key:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_appId', + question: 'Please provide your Firebase **Web** App ID:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_authDomain', + question: 'Please provide your Firebase **Web** Auth Domain:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_messagingSenderId', + question: 'Please provide your Firebase **Web** Messaging Sender ID:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_projectId', + question: 'Please provide your Firebase **Web** Project ID:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_storageBucket', + question: 'Please provide your Firebase **Web** Storage Bucket:', + platform: ['web'], + type: 'text', + }, + { + key: 'web_measurementId', + question: 'Please provide your Firebase **Web** Measurement ID:', + platform: ['web'], + type: 'text', + }, +]; diff --git a/starter/src/dart_runner.ts b/starter/src/dart_runner.ts new file mode 100644 index 000000000..279762192 --- /dev/null +++ b/starter/src/dart_runner.ts @@ -0,0 +1,91 @@ +import { exec } from 'child_process'; +import { ArgumentParseResult, Script } from './interfaces'; +import { + checkAndAskForMissingArgs, + findScript, + logError, + selectModules, +} from './utils'; +import { commonParameters } from './utility_scripts'; + +const parseArguments = (args: string[]): ArgumentParseResult => { + const scripts: string[] = []; + const argsArray: string[] = []; + for (const arg of args) { + if (arg.includes('=')) { + const [key, value] = arg.split('='); + argsArray.push(`${key}="${value.replace(/"/g, '\\"')}"`); + } else { + scripts.push(arg); + } + } + return { scripts, argsArray }; +}; + +const generateArgsForScript = ( + scriptObj: Script, + argsArray: string[] +): string => { + const allowedKeys = new Set([ + ...scriptObj.parameters.map((p) => p.key), + ...commonParameters.map((p) => p.key), + ]); + return argsArray + .filter((arg) => allowedKeys.has(arg.split('=')[0])) + .join(' '); +}; + +const executeCommand = (command: string): Promise => { + console.log(`Executing: ${command}`); + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + logError(`Command failed: ${command}`, error); + return reject(error); + } + if (stderr) console.error(`[stderr] ${stderr}`); + if (stdout) console.log(`[stdout] ${stdout}`); + resolve(); + }); + }); +}; + +const runScript = async ( + scriptObj: Script, + argsArray: string[] +): Promise => { + const dartArgs = generateArgsForScript(scriptObj, argsArray); + const command = `dart run ${scriptObj.path} ${dartArgs}`; + await executeCommand(command); +}; + +const runScriptsSequentially = async (list: Script[], argsArray: string[]) => { + for (const s of list) await runScript(s, argsArray); +}; + +(async () => { + try { + const [firstArg, ...restArgs] = process.argv.slice(2); + const bypass = restArgs.includes('bypass-questions=true'); + + if (firstArg === 'enable') { + const { scripts: toRun, argsArray } = parseArguments(restArgs); + const selected = + toRun.length > 0 ? toRun.map(findScript) : await selectModules(); + const updated = bypass + ? argsArray + : await checkAndAskForMissingArgs(selected, argsArray); + await runScriptsSequentially(selected, updated); + } else { + const scriptObj = findScript(firstArg); + const { argsArray } = parseArguments(restArgs); + const updated = bypass + ? argsArray + : await checkAndAskForMissingArgs([scriptObj], argsArray); + await runScript(scriptObj, updated); + } + } catch (error) { + logError('An error occurred', error); + process.exit(1); + } +})(); diff --git a/starter/src/interfaces.ts b/starter/src/interfaces.ts new file mode 100644 index 000000000..eac7d98b1 --- /dev/null +++ b/starter/src/interfaces.ts @@ -0,0 +1,20 @@ +export type Platform = 'ios' | 'android' | 'web'; + +export interface ArgumentParseResult { + scripts: string[]; + argsArray: string[]; +} + +export interface Script { + name: string; + path: string; + parameters: Parameter[]; +} + +export interface Parameter { + key: string; + question: string; + type: string; + choices?: string[]; + platform: Platform[]; +} diff --git a/starter/src/modules_scripts.ts b/starter/src/modules_scripts.ts new file mode 100644 index 000000000..a9a75c468 --- /dev/null +++ b/starter/src/modules_scripts.ts @@ -0,0 +1,313 @@ +import { + firebaseAndroidParameters, + firebaseIOSParameters, + firebaseWebParameters, +} from './common-params'; +import { Script } from './interfaces'; + +// Modules (called with `enable` command) +export const modules: Script[] = [ + { + name: 'camera', + path: 'scripts/modules/enable_camera.dart', + parameters: [ + { + key: 'cameraDescription', + question: 'Please provide a camera usage description for iOS: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'photoLibraryDescription', + question: + 'Please provide a description for accessing the photo library: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'file_manager', + path: 'scripts/modules/enable_files.dart', + parameters: [ + { + key: 'photoLibraryDescription', + question: + 'Please provide a description for accessing the photo library: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'musicDescription', + question: 'Please provide a description for accessing music files: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'contacts', + path: 'scripts/modules/enable_contacts.dart', + parameters: [ + { + key: 'contactsDescription', + question: 'Please provide a description for accessing contacts: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'plaid_connect', + path: 'scripts/modules/enable_connect.dart', + parameters: [ + { + key: 'cameraDescription', + question: 'Please provide a camera usage description: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'location', + path: 'scripts/modules/enable_location.dart', + parameters: [ + { + key: 'inUseLocationDescription', + question: + 'Please provide a description for using location services while the app is in use: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'alwaysUseLocationDescription', + question: + 'Please provide a description for using location services always: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'locationDescription', + question: + 'Please provide a description for using location services always and when the app is in use: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'deeplink', + path: 'scripts/modules/enable_deeplink.dart', + parameters: [ + { + key: 'branchIOLiveKey', + question: 'Please provide the live Branch.io key: ', + platform: ['android', 'ios', 'web'], + type: 'text', + }, + { + key: 'branchIOTestKey', + question: 'Please provide the test Branch.io key: ', + platform: ['android', 'ios', 'web'], + type: 'text', + }, + { + key: 'branchIOUseTestKey', + question: 'Are you using the test key? (yes/no): ', + type: 'toggle', + choices: ['yes', 'no'], + platform: ['android', 'ios', 'web'], + }, + { + key: 'branchIOScheme', + question: 'Please provide the URI scheme for deeplinking: ', + platform: ['android', 'ios', 'web'], + type: 'text', + }, + { + key: 'branchIOLinks', + question: 'Please provide a comma-separated list of deeplink URLs: ', + platform: ['android', 'ios', 'web'], + type: 'text', + }, + ], + }, + { + name: 'firebase_analytics', + path: 'scripts/modules/enable_firebase_analytics.dart', + parameters: [ + ...firebaseAndroidParameters, + ...firebaseIOSParameters, + ...firebaseWebParameters, + { + key: 'enableConsoleLogs', + question: 'Do you want to enable Firebase console logs? (yes/no): ', + type: 'toggle', + choices: ['yes', 'no'], + platform: ['android', 'ios', 'web'], + }, + ], + }, + { + name: 'notification', + path: 'scripts/modules/enable_notifications.dart', + parameters: [ + ...firebaseAndroidParameters, + ...firebaseIOSParameters, + ...firebaseWebParameters, + ], + }, + { + name: 'bracket', + path: 'scripts/modules/enable_bracket.dart', + parameters: [], + }, + { + name: 'network_info', + path: 'scripts/modules/enable_network_info.dart', + parameters: [ + { + key: 'inUseLocationDescription', + question: + 'Please provide a description for using location services while accessing network info: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'alwaysUseLocationDescription', + question: + 'Please provide a description for always using location services for network info: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'preciseLocationDescription', + question: + 'Please provide a description for using precise location services for network info: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'ai_chat', + path: 'scripts/modules/enable_chat.dart', + parameters: [], + }, + { + name: 'auth', + path: 'scripts/modules/enable_auth.dart', + parameters: [ + ...firebaseAndroidParameters, + ...firebaseIOSParameters, + ...firebaseWebParameters, + { + key: 'googleIOSClientId', + question: 'Please provide your iOS client ID: ', + type: 'text', + platform: [], + }, + { + key: 'googleAndroidClientId', + question: 'Please provide your Android client ID: ', + type: 'text', + platform: [], + }, + { + key: 'googleWebClientId', + question: 'Please provide your Web client ID: ', + type: 'text', + platform: [], + }, + { + key: 'googleServerClientId', + question: 'Please provide your server client ID: ', + type: 'text', + platform: [], + }, + ], + }, + { + name: 'bluetooth', + path: 'scripts/modules/enable_bluetooth.dart', + parameters: [ + { + key: 'bluetoothDescription', + question: 'Please provide a description for accessing Bluetooth: ', + platform: ['ios'], + type: 'text', + }, + { + key: 'bluetoothPeripheralDescription', + question: + 'Please provide a description for using Bluetooth peripherals: ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'biometric', + path: 'scripts/modules/enable_biometric.dart', + parameters: [ + { + key: 'faceIdDescription', + question: 'Please provide a description for Face ID usage (iOS): ', + platform: ['ios'], + type: 'text', + }, + ], + }, + { + name: 'qr_code', + path: 'scripts/modules/enable_qr_code.dart', + parameters: [], + }, + { + name: 'google_maps', + path: 'scripts/modules/enable_google_maps.dart', + parameters: [ + { + key: 'iOSGoogleMapsApiKey', + question: 'Please provide your Google Maps API key for iOS ', + platform: ['ios'], + type: 'text', + }, + { + key: 'androidGoogleMapsApiKey', + question: 'Please provide your Google Maps API key for Android ', + platform: ['android'], + type: 'text', + }, + { + key: 'webGoogleMapsApiKey', + question: 'Please provide your Google Maps API key for Web ', + platform: ['web'], + type: 'text', + }, + ], + }, + { + name: 'moengage', + path: 'scripts/modules/enable_moengage.dart', + parameters: [ + { + key: 'moengage_workspace_id', + question: 'Please provide your MoEngage Workspace ID: ', + platform: ['android', 'ios', 'web'], + type: 'text', + }, + { + key: 'enableConsoleLogs', + question: 'Do you want to enable MoEngage console logs? (yes/no): ', + type: 'toggle', + choices: ['yes', 'no'], + platform: ['android', 'ios', 'web'], + }, + ...firebaseAndroidParameters, + ...firebaseIOSParameters, + ...firebaseWebParameters, + ], + }, +]; diff --git a/starter/src/utility_scripts.ts b/starter/src/utility_scripts.ts new file mode 100644 index 000000000..6227d911f --- /dev/null +++ b/starter/src/utility_scripts.ts @@ -0,0 +1,46 @@ +import { Parameter, Script } from './interfaces'; + +// Common parameters available across scripts and modules +export const commonParameters: Parameter[] = [ + { + key: 'platform', + question: 'Which platform(s) are you targeting?', + type: 'select', + choices: ['ios', 'android', 'web'], + platform: ['android', 'ios', 'web'], + }, + { + key: 'ensemble_version', + question: 'Which version of ensemble are you using?', + type: 'text', + platform: ['android', 'ios', 'web'], + }, +]; + +// Custom Scripts (standalone Dart scripts) +export const scripts: Script[] = [ + { + name: 'generateKeystore', + path: 'scripts/generate_keystore.dart', + parameters: [ + { + key: 'storePassword', + question: 'Please provide the store password: ', + platform: ['android'], + type: 'text', + }, + { + key: 'keyPassword', + question: 'Please provide the key password: ', + platform: ['android'], + type: 'text', + }, + { + key: 'keyAlias', + question: 'Please provide the key alias: ', + platform: ['android'], + type: 'text', + }, + ], + }, +]; diff --git a/starter/src/utils.ts b/starter/src/utils.ts new file mode 100644 index 000000000..528d99953 --- /dev/null +++ b/starter/src/utils.ts @@ -0,0 +1,103 @@ +import prompts from 'prompts'; +import { Parameter, Platform, Script } from './interfaces'; +import { commonParameters, scripts } from './utility_scripts'; +import { modules } from './modules_scripts'; + +export const findScript = (name: string): Script => { + const script = + scripts.find((s) => s.name === name) || + modules.find((m) => m.name === name); + if (!script) throw new Error(`Script/module "${name}" not found.`); + return script; +}; + +export const logError = (message: string, error?: unknown) => { + console.error(`[Error] ${message}`); + if (error instanceof Error) console.error(`[Details] ${error.message}`); +}; + +export const selectModules = async (): Promise => { + const { selectedModules } = await prompts({ + type: 'multiselect', + name: 'selectedModules', + message: 'Please select the modules you want to enable:', + choices: modules.map((m) => ({ title: m.name, value: m.name })), + }); + return selectedModules.map(findScript); +}; + +const askForMissingArgs = async ( + params: Parameter[], + args: Record, + providedArgs: string[], + isCI: boolean +): Promise> => { + const questions: prompts.PromptObject[] = params + .filter((param) => { + const required = + (args.platform && param.platform.includes(args.platform as Platform)) ?? + true; + return ( + required && + !providedArgs.includes(param.key) && + !args[param.key] && + !isCI + ); + }) + .map((param) => ({ + type: param.type as prompts.PromptType, + name: param.key, + message: param.question, + choices: param.choices?.map((choice) => ({ + title: choice, + value: choice, + })), + validate: (value: any) => + value ? true : `Parameter "${param.key}" is required.`, + })); + + const answers = await prompts(questions); + return Object.fromEntries( + Object.entries(answers).map(([key, value]) => [ + key, + value === 'yes' ? 'true' : value === 'no' ? 'false' : value, + ]) + ); +}; + +export const checkAndAskForMissingArgs = async ( + selected: Script[], + argsArray: string[] +): Promise => { + const providedArgs = argsArray.map((a) => a.split('=')[0]); + const args = Object.fromEntries( + argsArray.map((arg) => { + const i = arg.indexOf('='); + return [arg.slice(0, i), arg.slice(i + 1).replace(/"/g, '')]; + }) + ); + const isCI = process.env.CI === 'true'; + + const commonAnswers = await askForMissingArgs( + commonParameters, + args, + providedArgs, + isCI + ); + Object.assign(args, commonAnswers); + + const allParams = selected.flatMap((s) => s.parameters); + const moduleAnswers = await askForMissingArgs( + allParams, + args, + providedArgs, + isCI + ); + Object.assign(args, moduleAnswers); + + return argsArray.concat( + ...Object.entries({ ...commonAnswers, ...moduleAnswers }).map( + ([k, v]) => `${k}="${v}"` + ) + ); +}; diff --git a/starter/tsconfig.json b/starter/tsconfig.json new file mode 100644 index 000000000..775b22998 --- /dev/null +++ b/starter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}