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/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 7febc0bb0..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 @@ -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..b0d0f95ce --- /dev/null +++ b/starter/scripts/modules/enable_bluetooth.dart @@ -0,0 +1,98 @@ +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 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..d33eb7ef7 --- /dev/null +++ b/starter/scripts/modules/enable_camera.dart @@ -0,0 +1,67 @@ +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', + } + ]; + + 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..d9022f8ee --- /dev/null +++ b/starter/src/modules_scripts.ts @@ -0,0 +1,306 @@ +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', + }, + ], + }, + { + 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"] +}