├── .bundle └── config ├── .github └── workflows │ └── end_to_end_test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── fastlane-plugin-flutter.gemspec ├── fastlane ├── Fastfile └── README.md ├── lib └── fastlane │ └── plugin │ ├── flutter.rb │ └── flutter │ ├── actions │ ├── flutter_action.rb │ ├── flutter_bootstrap_action.rb │ ├── flutter_build_action.rb │ └── flutter_generate_action.rb │ ├── base │ └── flutter_action_base.rb │ ├── helper │ ├── flutter_bootstrap_helper.rb │ ├── flutter_generate_build_runner_helper.rb │ ├── flutter_generate_intl_helper.rb │ └── flutter_helper.rb │ └── version.rb └── spec ├── flutter_bootstrap_action_spec.rb ├── flutter_build_action_spec.rb ├── flutter_generate_action_spec.rb ├── flutter_helper_spec.rb └── spec_helper.rb /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | BUNDLE_CLEAN: "true" 4 | -------------------------------------------------------------------------------- /.github/workflows/end_to_end_test.yml: -------------------------------------------------------------------------------- 1 | name: end-to-end test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [macOS-latest, ubuntu-latest, windows-latest] 17 | platform: [ios, android] 18 | exclude: 19 | # Can't build for iOS on Ubuntu and Windows. 20 | - os: ubuntu-latest 21 | platform: ios 22 | - os: windows-latest 23 | platform: ios 24 | runs-on: ${{ matrix.os }} 25 | steps: 26 | - uses: actions/checkout@v1 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: 3.3 30 | # Have to split multiple commands into steps due to a bug on Windows: 31 | # https://github.community/t5/GitHub-Actions/Windows-multi-line-step-run/td-p/30428 32 | - run: gem install --no-doc bundler 33 | - run: bundle install 34 | - run: bundle exec rake 35 | - name: 'Run-run rake with coverage upload' 36 | if: runner.os == 'Linux' && matrix.platform == 'android' 37 | run: bundle exec rake 38 | env: 39 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 40 | - name: 'Install JDK' 41 | if: matrix.platform == 'android' 42 | uses: actions/setup-java@v4 43 | with: 44 | java-version: '11' 45 | distribution: 'zulu' 46 | # For Windows: error: unable to create file <...>: Filename too long 47 | - run: git config --global core.longpaths true 48 | - run: bundle exec fastlane ${{ matrix.platform }} end_to_end_test 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | Gemfile.lock 3 | 4 | ## Documentation cache and generated files: 5 | /.yardoc/ 6 | /_yardoc/ 7 | /doc/ 8 | /rdoc/ 9 | fastlane/report.xml 10 | coverage 11 | test-results 12 | /vendor 13 | 14 | /.vscode 15 | 16 | ## end_to_end_test installs Flutter in this directory. 17 | /.flutter 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format d 4 | --format RspecJunitFormatter 5 | --out test-results/rspec/rspec.xml 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | require: 3 | - rubocop-rake 4 | - rubocop-rspec 5 | 6 | # Pre existing findings. May be good to fix one day. 7 | Metrics/AbcSize: 8 | Enabled: false 9 | Metrics/CyclomaticComplexity: 10 | Enabled: false 11 | Metrics/ClassLength: 12 | Enabled: false 13 | Metrics/MethodLength: 14 | Enabled: false 15 | Metrics/PerceivedComplexity: 16 | Enabled: false 17 | Metrics/BlockLength: 18 | Enabled: false 19 | Gemspec/RequiredRubyVersion: 20 | Enabled: false 21 | RSpec/SpecFilePathFormat: 22 | Enabled: false 23 | Naming/PredicateName: 24 | Enabled: false 25 | Style/MultilineBlockChain: 26 | Enabled: false 27 | Style/Documentation: 28 | Enabled: false 29 | RSpec/MessageSpies: 30 | Enabled: false 31 | RSpec/StubbedMock: 32 | Enabled: false 33 | RSpec/ExampleLength: 34 | Enabled: false 35 | RSpec/ExpectInHook: 36 | Enabled: false 37 | RSpec/MultipleExpectations: 38 | Enabled: false 39 | Layout/LeadingCommentSpace: 40 | Enabled: false 41 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.8.0 2 | 3 | - Parse "flutter build" command output even when it doesn't contain a period. 4 | 5 | ## 0.7.1 6 | 7 | - Remove `coverage_all_imports` file before codegeneration, as the latter may 8 | sometimes be confused. 9 | 10 | ## 0.7.0 11 | 12 | - Obsolete `vendor/flutter` (as it turns out, this never worked very well) and 13 | `FLUTTER_SDK_ROOT` environment variable. 14 | - Be verbose about why a certain Flutter location was determined as current by 15 | the plugin. 16 | 17 | ## 0.6.1 18 | 19 | - Detect `vendor/flutter` directory as default Flutter installation if there's a 20 | Flutter binary inside. 21 | 22 | ## 0.6.0 23 | 24 | - Support `.flutter` subdirectory for project-specific flutter installation 25 | (usually for Flutter version pinning). 26 | 27 | ## 0.5.0 28 | 29 | - Do not print a warning message when not able to parse "flutter build" output 30 | and "--config-only" argument is present. 31 | - Install "stable" version of Flutter by default (instead of "beta"). 32 | 33 | ## 0.4.2 34 | 35 | - Return Flutter SDK path from `flutter_bootstrap`. 36 | 37 | ## 0.4.1 38 | 39 | - Add `coverage_all_imports: true` support to `flutter_generate()`, which would 40 | generate a test importing all `.dart` files in `lib/`. This will make coverage 41 | tools consider percentage of overall project rather than files with non-zero 42 | coverage only. 43 | 44 | ## 0.4.0 45 | 46 | - Support `--split-per-abi` flag and return a list from `flutter_build()` in 47 | that case. 48 | 49 | ## 0.3.19 50 | 51 | - Fill in some well-known context variables in `flutter_build()` action. 52 | - Slightly expand on `flutter_generate()` command. 53 | 54 | ## 0.3.18 55 | 56 | - Add an example of how an `.ipa` file can be built for a Flutter app. 57 | - Fix `capture_stdout` parameter of `flutter` action to actually return stdout. 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Tests 2 | 3 | ### Re-generating demo project 4 | 5 | Create a new Flutter project: 6 | 7 | ```shell 8 | $ flutter create demo 9 | $ cd demo 10 | ``` 11 | 12 | Edit pubspec.yaml: 13 | 14 | ```diff 15 | dev_dependencies: 16 | flutter_test: 17 | sdk: flutter 18 | 19 | + intl_translation: any 20 | + build_runner: any 21 | ``` 22 | 23 | Install dependencies: 24 | 25 | ```shell 26 | $ flutter packages get 27 | ``` 28 | 29 | Create file `lib/intl/intl.dart`: 30 | 31 | ```dart 32 | import 'dart:async'; 33 | 34 | import 'messages_all.dart'; 35 | import 'package:intl/intl.dart'; 36 | 37 | class DemoIntl { 38 | String get helloWorld => Intl.message( 39 | 'Hello, World!', 40 | name: 'helloWorld', 41 | desc: 'Text displayed in the center of the login screen', 42 | ); 43 | } 44 | ``` 45 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source('https://rubygems.org') 4 | 5 | gemspec 6 | 7 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 8 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 9 | 10 | gem 'climate_control', '~> 0.2.0', group: :development 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Artem Sheremet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutter plugin 2 | 3 | [![Gem Version](https://badge.fury.io/rb/fastlane-plugin-flutter.svg)](https://badge.fury.io/rb/fastlane-plugin-flutter) 4 | [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-flutter) 5 | [![codecov](https://codecov.io/gh/dotdoom/fastlane-plugin-flutter/branch/master/graph/badge.svg)](https://codecov.io/gh/dotdoom/fastlane-plugin-flutter) 6 | [![Build Status](https://github.com/dotdoom/fastlane-plugin-flutter/workflows/end-to-end%20test/badge.svg?branch=master)](https://github.com/dotdoom/fastlane-plugin-flutter/actions?query=workflow%3A"end-to-end+test"+branch%3Amaster) 7 | 8 | Automated end-to-end test (download Flutter, create an app, build it) on the 9 | following platforms: 10 | 11 | * macOS (iOS) 12 | * macOS (Android) 13 | * Ubuntu Linux (Android) 14 | * Windows (Android) 15 | 16 | ## Getting Started 17 | 18 | This project is a [_fastlane_](https://github.com/fastlane/fastlane) plugin. To get started with `fastlane-plugin-flutter`, add it to your project by running: 19 | 20 | ```shell 21 | $ fastlane add_plugin flutter 22 | ``` 23 | 24 | ## About flutter 25 | 26 | Flutter actions plugin for Fastlane. 27 | 28 | ## Example 29 | 30 | Check out the [example `Fastfile`](fastlane/Fastfile) to see how to use this plugin. 31 | 32 | ## Run tests for this plugin 33 | 34 | To run both the tests, and code style validation, run 35 | 36 | ```shell 37 | $ bundle install 38 | $ bundle exec rake 39 | $ bundle exec fastlane end_to_end_test 40 | ``` 41 | 42 | To automatically fix many of the styling issues, use 43 | 44 | ```shell 45 | $ bundle install 46 | $ bundle exec rubocop -a 47 | ``` 48 | 49 | ## Issues and Feedback 50 | 51 | For any other issues and feedback about this plugin, please submit it to this repository. 52 | 53 | ## Troubleshooting 54 | 55 | If you have trouble using plugins, check out the [Plugins Troubleshooting](https://docs.fastlane.tools/plugins/plugins-troubleshooting/) guide. 56 | 57 | ## Using _fastlane_ Plugins 58 | 59 | For more information about how the `fastlane` plugin system works, check out the [Plugins documentation](https://docs.fastlane.tools/plugins/create-plugin/). 60 | 61 | ## About _fastlane_ 62 | 63 | _fastlane_ is the easiest way to automate beta deployments and releases for your iOS and Android apps. To learn more, check out [fastlane.tools](https://fastlane.tools). 64 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new 7 | 8 | require 'rubocop/rake_task' 9 | RuboCop::RakeTask.new(:rubocop) 10 | 11 | task(default: %i[spec rubocop]) 12 | -------------------------------------------------------------------------------- /fastlane-plugin-flutter.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'fastlane/plugin/flutter/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'fastlane-plugin-flutter' 9 | spec.version = Fastlane::Flutter::VERSION 10 | spec.author = 'Artem Sheremet' 11 | spec.email = 'artem@sheremet.ch' 12 | 13 | spec.summary = 'Flutter actions plugin for Fastlane' 14 | spec.homepage = 'https://github.com/dotdoom/fastlane-plugin-flutter' 15 | spec.license = 'MIT' 16 | 17 | spec.files = Dir['lib/**/*'] + %w[README.md LICENSE] 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | # Don't add a dependency to fastlane or fastlane_re 22 | # since this would cause a circular dependency 23 | 24 | # spec.add_dependency 'your-dependency', '~> 1.0.0' 25 | 26 | spec.add_development_dependency('bundler') 27 | spec.add_development_dependency('codecov') 28 | spec.add_development_dependency('fastlane', '>= 2.91.0') 29 | spec.add_development_dependency('pry') 30 | spec.add_development_dependency('rake') 31 | spec.add_development_dependency('rspec') 32 | spec.add_development_dependency('rspec_junit_formatter') 33 | spec.add_development_dependency('rubocop', '~> 1.64') 34 | spec.add_development_dependency('rubocop-rake') 35 | spec.add_development_dependency('rubocop-require_tools') 36 | spec.add_development_dependency('rubocop-rspec') 37 | spec.add_development_dependency('simplecov') 38 | end 39 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | default_platform(:android) 4 | 5 | platform :ios do 6 | desc 'Useful on CI, this lane installs Flutter.' 7 | lane :bootstrap do 8 | flutter_bootstrap(flutter_channel: 'stable') 9 | end 10 | end 11 | 12 | platform :android do 13 | desc 'Useful on CI, this lane installs Flutter and accepts some of the' 14 | desc 'Android SDK licenses. Note that it does not install Android SDK itself,' 15 | desc 'which is installed by Gradle automatically during the first build.' 16 | desc 'Nevertheless, ANDROID_SDK_ROOT or ANDROID_HOME environment variable' 17 | desc 'must point to a destination directory (which might be empty).' 18 | lane :bootstrap do 19 | flutter_bootstrap( 20 | flutter_channel: 'stable', 21 | # Only do this when building for Android, otherwise we would enforce 22 | # existence of ANDROID_SDK_ROOT environment variable, which is completely 23 | # unnecessary when building for iOS. 24 | android_licenses: { 25 | 'android-sdk-license' => '24333f8a63b6825ea9c5514f83c2829b004d1fee' 26 | } 27 | ) 28 | end 29 | end 30 | 31 | desc 'Generate files, format, lint, test and build project.' 32 | lane :build do 33 | # Generate intl and other files. 34 | flutter_generate( 35 | # File with all the Intl.message() calls. 36 | intl_strings_file: 'lib/strings.dart', 37 | # Actual locale of default values provided in that Dart file. 38 | intl_strings_locale: 'en_US', 39 | # Generate a file in test/ directory which imports all .dart files in the 40 | # project. This helps coverage tools account for files with zero coverage. 41 | coverage_all_imports: true 42 | ) 43 | 44 | # Reformat the source code. 45 | # This does not work, since the "format" command has been removed but 46 | # the deprecation warning mentioned a replacement that does not exist. 47 | #flutter(args: %w[format .]) 48 | 49 | # Uncomment the following on CI. For Git repositories, it will verify that 50 | # 'format' and 'generate' actions didn't change anything from what a developer 51 | # has been doing. 52 | # ensure_git_status_clean 53 | 54 | # Lint (statically find errors). 55 | flutter(args: %w[analyze]) 56 | 57 | # Run unit tests. 58 | flutter(args: %(test)) 59 | 60 | build_args = [] 61 | if lane_context[SharedValues::PLATFORM_NAME] == :android 62 | build_args << '--shrink' 63 | elsif lane_context[SharedValues::PLATFORM_NAME] == :ios 64 | build_args << '--no-codesign' 65 | end 66 | 67 | # Build a debug version of the app (release will require signing config). 68 | output_file = flutter_build( 69 | debug: true, 70 | 71 | # Uncomment the following to build a specific binary (apk, appbundle, ios 72 | # etc). When unspecified, the plugin will use Fastlane platform specific 73 | # default. 74 | # 75 | # build: 'appbundle', 76 | 77 | # Override version from pubspec.yaml. 78 | build_number: 123, 79 | build_name: '1.2.3', 80 | 81 | # Demo of custom build arguments appended to "flutter build" command line. 82 | build_args: 83 | ) 84 | 85 | # NOTE: for iOS, output_file will point to a .app directory instead of an 86 | # .ipa file: see https://github.com/flutter/flutter/issues/13065. 87 | # 88 | # flutter_build action helps you by setting up GYM_xxx environment variables 89 | # so that you can use gym() without parameters immediately afterwards: 90 | # 91 | # gym(silent: true, suppress_xcode_output: true) 92 | # 93 | # We do not run gym() here because it requires a provisioning profile, which 94 | # is not installed on the test server. 95 | # 96 | # Once gym() completes, it sets SharedValues::IPA_OUTPUT_PATH context variable 97 | # which is then automatically detected by upload_to_testflight or 98 | # upload_to_app_store actions, so those do not need to be configured any 99 | # further. 100 | 101 | UI.success("Built #{output_file}!") 102 | end 103 | 104 | ######## Internal tests for the plugin ######## 105 | 106 | # TODO(dotdoom): find a way of launching platform-agnostic lane with a platform: 107 | # $ bundle exec fastlane ios end_to_end_test 108 | # without resorting to creating multiple lanes. 109 | platform :ios do 110 | lane :end_to_end_test do 111 | platform_agnostic_end_to_end_test 112 | end 113 | end 114 | platform :android do 115 | lane :end_to_end_test do 116 | platform_agnostic_end_to_end_test 117 | end 118 | end 119 | 120 | desc "This is an internal test for Fastlane Flutter plugin. You shouldn't" 121 | desc 'need to do anything like that in your Fastfile.' 122 | desc "It uses 'bootstrap' lane from above to install Flutter, then creates a" 123 | desc "temporary (but real) Flutter project, and builds it via 'build' lane." 124 | lane :platform_agnostic_end_to_end_test do 125 | # The block version doesn't work on Windows for some reason: 126 | # `rmdir': [!] Directory not empty @ dir_s_rmdir 127 | root = Dir.mktmpdir('fastlane-plugin-flutter-') 128 | begin 129 | # Override environment in case Flutter / Android SDK is preinstalled. In 130 | # this test, we want to really test full installation procedure. 131 | ENV.delete('ANDROID_HOME') 132 | ENV['ANDROID_SDK_ROOT'] = File.join(root, 'android') 133 | 134 | # Install our dependencies. 135 | bootstrap 136 | 137 | app = File.join(root, 'myapp') 138 | flutter(args: ['create', app]) 139 | 140 | # Add dependencies on built_value and intl, and add some code to trigger 141 | # (and validate) generators. 142 | pubspec = File.join(app, 'pubspec.yaml') 143 | File.write(pubspec, File.read(pubspec).sub( 144 | 'dependencies:', 145 | "dependencies:\n" \ 146 | " built_value:\n" 147 | ).sub( 148 | 'dev_dependencies:', 149 | "dev_dependencies:\n" \ 150 | " build_runner:\n" \ 151 | " built_value_generator:\n" 152 | )) 153 | 154 | File.write(File.join(app, 'lib', 'message.dart'), <<-'DART') 155 | import 'package:built_value/built_value.dart'; 156 | part 'message.g.dart'; 157 | abstract class Message implements Built { 158 | String get text; 159 | factory Message([void Function(MessageBuilder) updates]) = _$Message; 160 | Message._(); 161 | } 162 | DART 163 | 164 | File.write(File.join(app, 'lib', 'splash.dart'), <<-'DART') 165 | import 'message.dart'; 166 | Future splashScreenMessage() async { 167 | // MessageBuilder class should be generated by built_value. 168 | return (MessageBuilder()..text = 'Hello, cruel world!').build(); 169 | } 170 | DART 171 | 172 | # A little hack. Since Fastlane always runs actions in 1 directory level 173 | # above current, we have to chdir 1 level into application dir structure: 174 | # https://docs.fastlane.tools/advanced/fastlane/#directory-behavior 175 | fastlane = File.join(app, 'fastlane') 176 | Dir.mkdir(fastlane) 177 | Dir.chdir(fastlane) do 178 | # Now that we've created our test application, run a real lane. 179 | build 180 | 181 | # Call build one more time with files already generated, as we expect it 182 | # to be idempotent. 183 | build 184 | 185 | # Call bootstrap one more time with Flutter already installed. This is 186 | # to ensure that upgrade path in our plugin is working correctly. 187 | bootstrap 188 | end 189 | ensure 190 | fastlane_require('fileutils') 191 | FileUtils.rm_rf(root) 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | Install _fastlane_ using 12 | ``` 13 | [sudo] gem install fastlane -NV 14 | ``` 15 | or alternatively using `brew install fastlane` 16 | 17 | # Available Actions 18 | ### build 19 | ``` 20 | fastlane build 21 | ``` 22 | Generate files, format, lint, test and build project. 23 | ### platform_agnostic_end_to_end_test 24 | ``` 25 | fastlane platform_agnostic_end_to_end_test 26 | ``` 27 | This is an internal test for Fastlane Flutter plugin. You shouldn't 28 | 29 | need to do anything like that in your Fastfile. 30 | 31 | It uses 'bootstrap' lane from above to install Flutter, then creates a 32 | 33 | temporary (but real) Flutter project, and builds it via 'build' lane. 34 | 35 | ---- 36 | 37 | ## iOS 38 | ### ios bootstrap 39 | ``` 40 | fastlane ios bootstrap 41 | ``` 42 | Useful on CI, this lane installs Flutter. 43 | ### ios end_to_end_test 44 | ``` 45 | fastlane ios end_to_end_test 46 | ``` 47 | 48 | 49 | ---- 50 | 51 | ## Android 52 | ### android bootstrap 53 | ``` 54 | fastlane android bootstrap 55 | ``` 56 | Useful on CI, this lane installs Flutter and accepts some of the 57 | 58 | Android SDK licenses. Note that it does not install Android SDK itself, 59 | 60 | which is installed by Gradle automatically during the first build. 61 | 62 | Nevertheless, ANDROID_SDK_ROOT or ANDROID_HOME environment variable 63 | 64 | must point to a destination directory (which might be empty). 65 | ### android end_to_end_test 66 | ``` 67 | fastlane android end_to_end_test 68 | ``` 69 | 70 | 71 | ---- 72 | 73 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. 74 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 75 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 76 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/plugin/flutter/version' 4 | 5 | module Fastlane 6 | module Flutter 7 | # Return all .rb files inside the "actions" and "helper" directory 8 | def self.all_classes 9 | Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))] 10 | end 11 | end 12 | end 13 | 14 | # By default we want to import all available actions and helpers 15 | # A plugin can contain any number of actions and plugins 16 | Fastlane::Flutter.all_classes.each do |current| 17 | require current 18 | end 19 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/actions/flutter_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/action' 4 | require_relative '../base/flutter_action_base' 5 | require_relative '../helper/flutter_helper' 6 | 7 | module Fastlane 8 | module Actions 9 | class FlutterAction < Action 10 | extend FlutterActionBase 11 | 12 | def self.run(params) 13 | if params[:capture_stdout] 14 | Helper::FlutterHelper.flutter(*params[:args]) do |_status, output| 15 | output 16 | end 17 | else 18 | Helper::FlutterHelper.flutter(*params[:args]) 19 | end 20 | end 21 | 22 | def self.description 23 | 'Run "flutter" binary with the specified arguments' 24 | end 25 | 26 | def self.available_options 27 | [ 28 | FastlaneCore::ConfigItem.new( 29 | key: :args, 30 | env_name: 'FL_FLUTTER_ARGS', 31 | description: 'Arguments to Flutter command', 32 | type: Array 33 | ), 34 | FastlaneCore::ConfigItem.new( 35 | key: :capture_stdout, 36 | env_name: 'FL_FLUTTER_CAPTURE_STDOUT', 37 | description: 'Do not print stdout of the command, but return it', 38 | optional: true, 39 | type: Boolean, 40 | default_value: false 41 | ) 42 | ] 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/actions/flutter_bootstrap_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/action' 4 | require_relative '../base/flutter_action_base' 5 | require_relative '../helper/flutter_helper' 6 | require_relative '../helper/flutter_bootstrap_helper' 7 | 8 | module Fastlane 9 | module Actions 10 | class FlutterBootstrapAction < Action 11 | extend FlutterActionBase 12 | 13 | FLUTTER_REMOTE_REPOSITORY = 'https://github.com/flutter/flutter.git' 14 | 15 | def self.run(params) 16 | if params[:android_licenses] 17 | Helper::FlutterBootstrapHelper.accept_licenses( 18 | File.join(android_sdk_root!, 'licenses'), 19 | params[:android_licenses] 20 | ) 21 | end 22 | 23 | # Upgrade or install Flutter SDK. 24 | flutter_sdk_root = Helper::FlutterHelper.flutter_sdk_root 25 | flutter_channel = params[:flutter_channel] 26 | if Helper::FlutterHelper.flutter_installed? 27 | if flutter_channel 28 | UI.message("Making sure Flutter is on channel #{flutter_channel}") 29 | Helper::FlutterHelper.flutter('channel', flutter_channel) {} 30 | end 31 | if params[:flutter_auto_upgrade] && 32 | need_upgrade_to_channel?(flutter_sdk_root, flutter_channel) 33 | UI.message("Upgrading Flutter SDK in #{flutter_sdk_root}...") 34 | Helper::FlutterHelper.flutter('upgrade') {} 35 | end 36 | else 37 | Helper::FlutterHelper.git( 38 | 'clone', # no --depth limit to keep Flutter tag-based versioning. 39 | "--branch=#{flutter_channel || 'stable'}", 40 | '--quiet', 41 | '--', 42 | FLUTTER_REMOTE_REPOSITORY, 43 | flutter_sdk_root 44 | ) 45 | end 46 | 47 | # Return installation path of Flutter SDK. 48 | flutter_sdk_root 49 | end 50 | 51 | def self.need_upgrade_to_channel?(flutter_sdk_root, flutter_channel) 52 | # No channel specified -- always upgrade. 53 | return true unless flutter_channel 54 | 55 | remote_hash = Helper::FlutterHelper.git( 56 | 'ls-remote', FLUTTER_REMOTE_REPOSITORY, flutter_channel 57 | ) do |status, output, _errors_thread| 58 | output.split[0].strip if status.success? 59 | end 60 | local_hash = Helper::FlutterHelper.git( 61 | '-C', flutter_sdk_root, 'rev-parse', 'HEAD' 62 | ) do |status, output, _errors_thread| 63 | output.strip if status.success? 64 | end 65 | 66 | if !local_hash.nil? && local_hash == remote_hash 67 | UI.message('Local and remote Flutter repository hashes match ' \ 68 | "(#{local_hash}), no upgrade necessary. Keeping Git " \ 69 | 'index untouched!') 70 | false 71 | else 72 | UI.message("Local hash (#{local_hash}) of Flutter repository " \ 73 | "differs from remote (#{remote_hash}), upgrading") 74 | true 75 | end 76 | end 77 | 78 | def self.android_sdk_root! 79 | (ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']).tap do |path| 80 | unless path 81 | UI.build_failure!('Android SDK directory environment variables ' \ 82 | 'are not set. See ' \ 83 | 'https://developer.android.com/studio/command-line/variables') 84 | end 85 | end 86 | end 87 | 88 | def self.description 89 | 'Flutter SDK installation, upgrade and application bootstrap' 90 | end 91 | 92 | def self.available_options 93 | [ 94 | FastlaneCore::ConfigItem.new( 95 | key: :flutter_channel, 96 | env_name: 'FL_FLUTTER_CHANNEL', 97 | description: 'Flutter SDK channel (keep existing if unset)', 98 | optional: true, 99 | type: String 100 | ), 101 | FastlaneCore::ConfigItem.new( 102 | key: :flutter_auto_upgrade, 103 | env_name: 'FL_FLUTTER_AUTO_UPGRADE', 104 | description: 'Automatically upgrade Flutter when already installed', 105 | default_value: true, 106 | optional: true, 107 | is_string: false # official replacement for "type: Boolean" 108 | ), 109 | FastlaneCore::ConfigItem.new( 110 | key: :android_licenses, 111 | description: 'Map of file names to hash values of accepted ' \ 112 | 'Android SDK linceses, which may be found in ' \ 113 | '$ANDROID_SDK_ROOT/licenses/ on developer workstations. Gradle ' \ 114 | 'will refuse to install SDK unless licenses are accepted', 115 | optional: true, 116 | type: Hash 117 | ) 118 | ] 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/actions/flutter_build_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/action' 4 | require_relative '../base/flutter_action_base' 5 | require_relative '../helper/flutter_helper' 6 | 7 | module Fastlane 8 | module Actions 9 | module SharedValues 10 | FLUTTER_OUTPUT = :FLUTTER_OUTPUT 11 | end 12 | 13 | class FlutterBuildAction < Action 14 | extend FlutterActionBase 15 | 16 | FASTLANE_PLATFORM_TO_BUILD = { 17 | ios: 'ios', 18 | android: 'apk' 19 | }.freeze 20 | 21 | def self.run(params) 22 | # "flutter build" args list. 23 | build_args = [] 24 | 25 | if params[:build] 26 | build_args.push(params[:build]) 27 | elsif (fastlane_platform = lane_context[SharedValues::PLATFORM_NAME] || 28 | lane_context[SharedValues::DEFAULT_PLATFORM]) 29 | build_args.push(FASTLANE_PLATFORM_TO_BUILD[fastlane_platform]) 30 | else 31 | UI.user_error!('flutter_build action "build" parameter is not ' \ 32 | 'specified and cannot be inferred from Fastlane context.') 33 | end 34 | 35 | process_deprecated_params(params, build_args) 36 | 37 | build_args.push('--debug') if params[:debug] 38 | 39 | if (build_number = params[:build_number] || 40 | lane_context[SharedValues::BUILD_NUMBER]) 41 | build_args.push('--build-number', build_number.to_s) 42 | end 43 | 44 | if (build_name = params[:build_name] || 45 | lane_context[SharedValues::VERSION_NUMBER]) 46 | build_args.push('--build-name', build_name.to_s) 47 | end 48 | 49 | build_args += params[:build_args] || [] 50 | 51 | Helper::FlutterHelper.flutter('build', *build_args) do |status, res| 52 | if status.success? 53 | process_build_output(res, build_args) 54 | # gym (aka build_ios_app) action call may follow build; let's help 55 | # it identify the project, since Flutter project structure is 56 | # usually standard. 57 | publish_gym_defaults(build_args) 58 | else 59 | # Print stdout from "flutter build" because it may contain useful 60 | # details about failures, and it's normally not very verbose. 61 | UI.command_output(res) 62 | end 63 | # Tell upstream to NOT ignore error. 64 | false 65 | end 66 | 67 | # Fill in some well-known context variables so that next commands may 68 | # pick them up. 69 | case params[:build] 70 | when 'apk' 71 | lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH] = 72 | lane_context[SharedValues::FLUTTER_OUTPUT] 73 | when 'appbundle' 74 | lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] = 75 | lane_context[SharedValues::FLUTTER_OUTPUT] 76 | end 77 | 78 | lane_context[SharedValues::FLUTTER_OUTPUT] 79 | end 80 | 81 | def self.publish_gym_defaults(build_args) 82 | ENV['GYM_WORKSPACE'] ||= 'ios/Runner.xcworkspace' 83 | ENV['GYM_BUILD_PATH'] ||= 'build/ios' 84 | ENV['GYM_OUTPUT_DIRECTORY'] ||= 'build' 85 | return if ENV.include?('GYM_SCHEME') 86 | 87 | # Do some parsing on args. Is there a less ugly way? 88 | build_args.each.with_index do |arg, index| 89 | if arg.start_with?('--flavor', '-flavor') 90 | ENV['GYM_SCHEME'] = if arg.include?('=') 91 | arg.split('=', 2).last 92 | else 93 | build_args[index + 1] 94 | end 95 | end 96 | end 97 | end 98 | 99 | def self.process_deprecated_params(params, build_args) 100 | return if params[:codesign].nil? 101 | 102 | UI.deprecated(<<~"MESSAGE") 103 | flutter_build parameter "codesign" is deprecated. Use 104 | 105 | flutter_build( 106 | build_args: ["--#{params[:codesign] == false ? 'no-' : ''}codesign"] 107 | ) 108 | 109 | form instead. 110 | MESSAGE 111 | 112 | return unless params[:codesign] == false 113 | 114 | build_args.push('--no-codesign') 115 | end 116 | 117 | def self.process_build_output(output, build_args) 118 | artifacts = output.scan(/Built (.*?)(:? \([^)]*\))?[.]?$/) 119 | .map { |path| File.absolute_path(path[0]) } 120 | if artifacts.size == 1 121 | lane_context[SharedValues::FLUTTER_OUTPUT] = artifacts.first 122 | elsif artifacts.size > 1 123 | # Could be the result of "flutter build apk --split-per-abi". 124 | lane_context[SharedValues::FLUTTER_OUTPUT] = artifacts 125 | elsif build_args.include?('--config-only') 126 | UI.message('Config-only "build" detected, no output file name') 127 | else 128 | UI.important('Cannot parse built file path from "flutter build"') 129 | end 130 | end 131 | 132 | def self.description 133 | 'Run "flutter build" to build a Flutter application' 134 | end 135 | 136 | def self.category 137 | :building 138 | end 139 | 140 | def self.return_value 141 | 'A path to the built file, if available' 142 | end 143 | 144 | def self.available_options 145 | [ 146 | FastlaneCore::ConfigItem.new( 147 | key: :build, 148 | env_name: 'FL_FLUTTER_BUILD', 149 | description: 'Type of Flutter build (e.g. apk, appbundle, ios)', 150 | optional: true, 151 | type: String 152 | ), 153 | FastlaneCore::ConfigItem.new( 154 | key: :debug, 155 | env_name: 'FL_FLUTTER_DEBUG', 156 | description: 'Build a Debug version of the app if true', 157 | optional: true, 158 | type: Boolean, 159 | default_value: false 160 | ), 161 | FastlaneCore::ConfigItem.new( 162 | key: :codesign, 163 | env_name: 'FL_FLUTTER_CODESIGN', 164 | description: 'Set to false to skip iOS app signing. This may be ' \ 165 | 'useful e.g. on CI or when signed later by Fastlane "sigh"', 166 | optional: true, 167 | type: Boolean 168 | ), 169 | FastlaneCore::ConfigItem.new( 170 | key: :build_number, 171 | env_name: 'FL_FLUTTER_BUILD_NUMBER', 172 | description: 'Override build number specified in pubspec.yaml', 173 | optional: true, 174 | type: Integer 175 | ), 176 | FastlaneCore::ConfigItem.new( 177 | key: :build_name, 178 | env_name: 'FL_FLUTTER_BUILD_NAME', 179 | description: <<-'DESCRIPTION', 180 | Override build name specified in pubspec.yaml. 181 | NOTE: for App Store, build name must be in the format of at most 3 182 | integeres separated by a dot ("."). 183 | DESCRIPTION 184 | optional: true 185 | ), 186 | FastlaneCore::ConfigItem.new( 187 | key: :build_args, 188 | description: 'An array of extra arguments for "flutter build"', 189 | optional: true, 190 | type: Array 191 | ) 192 | ] 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/actions/flutter_generate_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/action' 4 | require_relative '../base/flutter_action_base' 5 | require_relative '../helper/flutter_helper' 6 | require_relative '../helper/flutter_generate_intl_helper' 7 | require_relative '../helper/flutter_generate_build_runner_helper' 8 | 9 | module Fastlane 10 | module Actions 11 | class FlutterGenerateAction < Action 12 | extend FlutterActionBase 13 | 14 | # Although this file is autogenerated, we should not call it ".g.dart", 15 | # because it is common for coverage configuration to exclude such files. 16 | # Note that it's also common to configure Dart analyser to exclude these 17 | # files from checks; this won't match, and we have to use ignore_for_file 18 | # for all known lint rules that we might be breaking. 19 | ALL_IMPORTS_TEST_FILE = 'test/all_imports_for_coverage_test.dart' 20 | 21 | def self.run(params) 22 | Helper::FlutterHelper.flutter(*%w[packages get]) {} 23 | 24 | if params[:coverage_all_imports] && File.exist?(ALL_IMPORTS_TEST_FILE) 25 | # This file may somehow confuse codegeneration (e.g. built_value). 26 | File.delete(ALL_IMPORTS_TEST_FILE) 27 | end 28 | 29 | # In an ideal world, this should be a part of build_runner: 30 | # https://github.com/dart-lang/intl_translation/issues/32 31 | # Generate Intl messages before others, since these are static and 32 | # others may be not. 33 | if generate_translation? 34 | Helper::FlutterGenerateIntlHelper.generate( 35 | params[:intl_strings_file], params[:intl_strings_locale] 36 | ) 37 | end 38 | 39 | if Helper::FlutterHelper.dev_dependency?('build_runner') 40 | UI.message('Found build_runner dependency, running build...') 41 | Helper::FlutterGenerateBuildRunnerHelper.build 42 | end 43 | 44 | return unless params[:coverage_all_imports] 45 | 46 | UI.message("Generating #{ALL_IMPORTS_TEST_FILE} for coverage...") 47 | 48 | dart_file_literals = Dir['lib/**/*.dart'].reject do |file_name| 49 | # ".g.dart" files often are "part of" files and can not be imported 50 | # directly. Commonly coverage for generated files is not that useful 51 | file_name.end_with?('.g.dart') 52 | end.map do |file_name| 53 | Helper::FlutterHelper 54 | .import_path_for_test(file_name, '..') 55 | .gsub("'", "\\\\'") 56 | end 57 | 58 | File.write( 59 | ALL_IMPORTS_TEST_FILE, 60 | <<~DART 61 | // This file is autogenerated by fastlane flutter_generate action. 62 | // It imports all files in lib/ so that test coverage in percentage 63 | // of overall project is calculated correctly. Do not modify this 64 | // file manually! 65 | 66 | // ignore_for_file: unused_import, directives_ordering 67 | // ignore_for_file: avoid_relative_lib_imports 68 | // ignore_for_file: lines_longer_than_80_chars 69 | 70 | #{dart_file_literals.map { |fn| "import '#{fn}';" }.sort.join("\n")} 71 | 72 | void main() {} 73 | DART 74 | ) 75 | end 76 | 77 | def self.generate_translation? 78 | Helper::FlutterHelper.dev_dependency?('intl_translation') 79 | end 80 | 81 | def self.description 82 | '(1) Run `flutter packages get`; ' \ 83 | '(2) Run `build_runner build` if build_runner is in dev_dependencies;' \ 84 | ' ' \ 85 | '(3) According to `package:intl`, take `$strings_file` and generate ' \ 86 | '`${strings_file.dirname}/arb/intl_messages.arb`, then take all ' \ 87 | 'files matching `${strings_file.dirname}/intl_*.arb`, fix them and ' \ 88 | 'generate .dart files from them. ' \ 89 | '(4) Generate an empty test importing all files, which would be used ' \ 90 | 'to calculate correct full coverage numbers.' 91 | end 92 | 93 | def self.available_options 94 | # https://docs.fastlane.tools/advanced/actions/#configuration-files 95 | [ 96 | FastlaneCore::ConfigItem.new( 97 | key: :intl_strings_file, 98 | env_name: 'FL_FLUTTER_INTL_STRINGS_FILE', 99 | description: 'Path to source .dart file with Intl.message calls', 100 | verify_block: proc do |value| 101 | UI.user_error!("File `#{value}' does not exist") if generate_translation? && !File.exist?(value) 102 | end, 103 | default_value: 'lib/intl/intl.dart' 104 | ), 105 | FastlaneCore::ConfigItem.new( 106 | key: :intl_strings_locale, 107 | env_name: 'FL_FLUTTER_INTL_STRINGS_LOCALE', 108 | description: 'Locale of the default data in the strings_file', 109 | optional: true 110 | ), 111 | FastlaneCore::ConfigItem.new( 112 | key: :coverage_all_imports, 113 | env_name: 'FL_FLUTTER_COVERAGE_ALL_IMPORTS', 114 | description: <<-DESCRIPTION, 115 | Set to true to generate an empty test importing all .dart files in 116 | lib/, which would allow calculating correct coverage numbers for the 117 | whole project. NOTE: Don't forget to add 118 | /#{ALL_IMPORTS_TEST_FILE} 119 | to .gitignore! 120 | DESCRIPTION 121 | optional: true, 122 | type: Boolean 123 | ) 124 | ] 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/base/flutter_action_base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane/action' 4 | 5 | module Fastlane 6 | module Actions 7 | module FlutterActionBase 8 | def authors 9 | ['github.com/dotdoom'] 10 | end 11 | 12 | def is_supported?(platform) 13 | # Also support nil (root lane). 14 | [nil, :ios, :android].include?(platform) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/helper/flutter_bootstrap_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fastlane_core/ui/ui' 4 | require 'fileutils' 5 | 6 | module Fastlane 7 | module Helper 8 | class FlutterBootstrapHelper 9 | def self.accept_licenses(licenses_directory, licenses) 10 | FileUtils.mkdir_p(licenses_directory) 11 | licenses.each_pair do |license, hash| 12 | license_file = File.join(licenses_directory, license) 13 | next if File.exist?(license_file) && 14 | File.readlines(license_file).map(&:strip).include?(hash) 15 | 16 | UI.message("Updating Android SDK license in #{license_file}...") 17 | File.open(license_file, 'a') { |f| f.puts('', hash) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/helper/flutter_generate_build_runner_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | 5 | module Fastlane 6 | module Helper 7 | class FlutterGenerateBuildRunnerHelper 8 | def self.build 9 | Helper::FlutterHelper.flutter( 10 | *%w[packages pub run build_runner build --delete-conflicting-outputs] 11 | ) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/helper/flutter_generate_intl_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'json' 5 | require 'yaml' 6 | 7 | require_relative 'flutter_helper' 8 | 9 | module Fastlane 10 | module Helper 11 | end 12 | end 13 | 14 | module Fastlane 15 | module Helper 16 | class FlutterGenerateIntlHelper 17 | def self.generate(messages_filename, messages_locale = nil) 18 | dart_files_dirname = File.dirname(messages_filename) 19 | arb_files_dirname = File.join(dart_files_dirname, 'arb') 20 | full_arb_filename = generate_arb_from_dart( 21 | messages_filename, messages_locale, arb_files_dirname 22 | ) 23 | 24 | arb_filenames = amend_arb_files(arb_files_dirname, full_arb_filename) 25 | 26 | unless messages_locale 27 | # Don't generate .dart for the ARB generated from original, unless it 28 | # has its own locale. 29 | arb_filenames.delete(full_arb_filename) 30 | end 31 | 32 | Fastlane::UI.message('Generating .dart files from .arb...') 33 | Fastlane::Helper::FlutterHelper.flutter( 34 | *%W[packages pub run intl_translation:generate_from_arb 35 | --output-dir=#{dart_files_dirname} 36 | --no-use-deferred-loading 37 | #{messages_filename}] + arb_filenames 38 | ) 39 | end 40 | 41 | def self.amend_arb_files(arb_files_dirname, full_arb_filename) 42 | full_arb_json = JSON.parse(File.read(full_arb_filename)) 43 | 44 | # Sort files for consistency, because generated messages_all.dart will 45 | # have imports ordered as in the command line below. 46 | arb_filenames = Dir.glob(File.join(arb_files_dirname, 'intl_*.arb')).sort 47 | arb_filenames.each do |arb_filename| 48 | arb_json = JSON.parse(File.read(arb_filename)) 49 | if arb_filename != full_arb_filename 50 | Fastlane::UI.message("Amending #{arb_filename}...") 51 | full_arb_json.each_pair do |k, v| 52 | # Ignore @@keys. We don't want to copy @@locale over all files, and 53 | # it's often unspecified to be inferred from file name. 54 | arb_json[k] ||= v unless k.start_with?('@@') 55 | end 56 | arb_json.keep_if { |k| full_arb_json.key?(k) } 57 | end 58 | File.write(arb_filename, "#{JSON.pretty_generate(arb_json)}\n") 59 | end 60 | end 61 | 62 | def self.generate_arb_from_dart(dart_filename, dart_locale, arb_dirname) 63 | arb_filename = File.join(arb_dirname, 'intl_messages.arb') 64 | Fastlane::UI.message("Generating #{arb_filename} from #{dart_filename}...") 65 | 66 | if File.exist?(arb_filename) 67 | arb_file_was = File.read(arb_filename) 68 | else 69 | # The file may not exist on the first run. Then it's also probable that 70 | # the output directory does not exist yet. 71 | FileUtils.mkdir_p(arb_dirname) 72 | end 73 | 74 | extract_to_arb_options = ["--output-dir=#{arb_dirname}"] 75 | extract_to_arb_options.push("--locale=#{dart_locale}") if dart_locale 76 | 77 | Fastlane::Helper::FlutterHelper.flutter( 78 | *%w[packages pub run intl_translation:extract_to_arb], 79 | *extract_to_arb_options, dart_filename 80 | ) 81 | 82 | # intl will update @@last_modified even if there are no updates; this 83 | # leaves Git directory unnecessary dirty. If that's the only change, 84 | # just restore the previous contents. 85 | if arb_file_was && restore_last_modified(arb_filename, arb_file_was) 86 | Fastlane::UI.message( 87 | "@@last_modified has been restored in #{arb_filename}" 88 | ) 89 | end 90 | 91 | arb_filename 92 | end 93 | 94 | def self.restore_last_modified(filename, old_content) 95 | new_content_tree = JSON.parse(File.read(filename)) 96 | old_content_tree = JSON.parse(old_content) 97 | new_content_tree['@@last_modified'] = old_content_tree['@@last_modified'] 98 | 99 | # Use to_json to compare the objects deep and in consistent format. 100 | if new_content_tree.to_json == old_content_tree.to_json 101 | # Except for the @@last_modified attribute that we replaced 102 | # above, the objects are identical. Restore previous timestamp. 103 | File.write(filename, old_content) 104 | return true 105 | end 106 | 107 | false 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/helper/flutter_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'shellwords' 4 | require 'yaml' 5 | 6 | module Fastlane 7 | module Helper 8 | class FlutterHelper 9 | def self.flutter(*argv, &block) 10 | execute(flutter_binary, *argv, &block) 11 | end 12 | 13 | def self.git(*argv, &block) 14 | execute('git', *argv, &block) 15 | end 16 | 17 | def self.flutter_sdk_root 18 | # Support flutterw and compatible projects. 19 | # Prefixing directory name with "." has a nice effect that Flutter tools 20 | # such as "format" and "lint" will not recurse into this subdirectory 21 | # while analyzing the project itself. This works immensely better than 22 | # e.g. vendor/flutter. 23 | pinned_flutter_path = File.join(Dir.pwd, '.flutter') 24 | 25 | @flutter_sdk_root ||= File.expand_path( 26 | if flutter_installed?(pinned_flutter_path) 27 | UI.message("Determined Flutter location as #{pinned_flutter_path}" \ 28 | " because 'flutter' executable exists there.") 29 | pinned_flutter_path 30 | elsif ENV.include?('FLUTTER_ROOT') 31 | # FLUTTER_ROOT is a standard environment variable from Flutter. 32 | UI.message("Determined Flutter location as #{ENV['FLUTTER_ROOT']}" \ 33 | ' because environment variable FLUTTER_ROOT points there' \ 34 | " (current directory is #{Dir.pwd}).") 35 | ENV['FLUTTER_ROOT'] 36 | elsif (flutter_binary = FastlaneCore::CommandExecutor.which('flutter')) 37 | location = File.dirname(File.dirname(flutter_binary)) 38 | UI.message("Determined Flutter location as #{location} because"\ 39 | " 'flutter' executable in PATH is located there" \ 40 | " (current directory is #{Dir.pwd}).") 41 | location 42 | else 43 | # Where we'd prefer to install flutter. 44 | UI.message('Determined desired Flutter location as' \ 45 | " #{pinned_flutter_path} because that's where this fastlane" \ 46 | ' plugin would install Flutter by default.') 47 | pinned_flutter_path 48 | end 49 | ) 50 | end 51 | 52 | def self.flutter_installed?(custom_flutter_root = nil) 53 | # Can't use File.executable? because on Windows it has to be .exe. 54 | File.exist?(flutter_binary(custom_flutter_root)) 55 | end 56 | 57 | def self.flutter_binary(custom_flutter_root = nil) 58 | File.join(custom_flutter_root || flutter_sdk_root, 'bin', 'flutter') 59 | end 60 | 61 | def self.dev_dependency?(package) 62 | (YAML.load_file('pubspec.yaml')['dev_dependencies'] || {}).key?(package) 63 | end 64 | 65 | def self.pub_package_name 66 | YAML.load_file('pubspec.yaml')['name'] 67 | end 68 | 69 | def self.import_path_for_test(file_to_import, relative_path) 70 | return File.join(relative_path, file_to_import) unless file_to_import.start_with?('lib/') 71 | 72 | # Import file schema in tests have to match files in lib/ exactly. From 73 | # Dart perspective, symbols in files imported via relative and 74 | # "package:" file paths are different symbols. 75 | package_specification = "package:#{pub_package_name}/" 76 | if File.read(file_to_import, 4096).include?(package_specification) 77 | # If there's a package reference in the first few bytes of the file, 78 | # chances are, it's using "package:..." imports. Indeed, checking the 79 | # file itself isn't sufficient to explore all of its dependencies, but 80 | # we expect imports to be consistent in the project. 81 | "#{package_specification}#{file_to_import['lib/'.size..]}" 82 | else 83 | File.join(relative_path, file_to_import) 84 | end 85 | end 86 | 87 | def self.execute(*command) 88 | # TODO(dotdoom): make CommandExecutor (and Actions.sh) behave similarly. 89 | command = command.shelljoin 90 | UI.command(command) 91 | Open3.popen3(command) do |stdin, stdout, stderr, wait_thread| 92 | errors_thread = Thread.new { stderr.read } 93 | stdin.close 94 | 95 | if block_given? 96 | output = stdout.read 97 | ignore_error = yield(wait_thread.value, output, errors_thread) 98 | else 99 | stdout.each_line do |stdout_line| 100 | UI.command_output(stdout_line.chomp) 101 | end 102 | end 103 | 104 | unless wait_thread.value.success? || (ignore_error == true) 105 | UI.shell_error!(<<~ERROR) 106 | The following command has failed: 107 | 108 | $ #{command} 109 | [#{wait_thread.value}] 110 | 111 | #{errors_thread.value} 112 | ERROR 113 | end 114 | 115 | ignore_error 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/flutter/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fastlane 4 | module Flutter 5 | VERSION = '0.8.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/flutter_bootstrap_action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'climate_control' 4 | 5 | describe Fastlane::Actions::FlutterBootstrapAction do 6 | describe '#run' do 7 | describe 'android_licenses' do 8 | it 'Fails when Android licenses are specified but no SDK path' do 9 | ClimateControl.modify(ANDROID_HOME: nil, ANDROID_SDK_ROOT: nil) do 10 | expect do 11 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 12 | lane :test do 13 | flutter_bootstrap( 14 | android_licenses: { 15 | sdk: 'abcdef', 16 | }, 17 | ) 18 | end 19 | FASTLANE 20 | end.to raise_error(FastlaneCore::Interface::FastlaneBuildFailure) 21 | end 22 | end 23 | 24 | it 'Installs Android licenses when specified' do 25 | ClimateControl.modify( 26 | ANDROID_HOME: nil, 27 | ANDROID_SDK_ROOT: '/tmp/android_sdk_root' 28 | ) do 29 | expect(Fastlane::Helper::FlutterBootstrapHelper) 30 | .to receive(:accept_licenses) 31 | .with('/tmp/android_sdk_root/licenses', { sdk: 'abcdef' }) 32 | 33 | expect(Fastlane::Helper::FlutterHelper) 34 | .to receive(:flutter_installed?) 35 | .and_return(true) 36 | expect(Fastlane::Helper::FlutterHelper) 37 | .to receive(:flutter_sdk_root) 38 | .and_return('/tmp/flutter') 39 | 40 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 41 | lane :test do 42 | flutter_bootstrap( 43 | flutter_auto_upgrade: false, 44 | android_licenses: { 45 | sdk: 'abcdef', 46 | }, 47 | ) 48 | end 49 | FASTLANE 50 | end 51 | end 52 | end 53 | 54 | describe 'flutter_...' do 55 | before do 56 | expect(Fastlane::Helper::FlutterHelper) 57 | .to receive(:flutter_sdk_root) 58 | .and_return('/tmp/flutter') 59 | end 60 | 61 | it 'Returns SDK path when Flutter is installed and no updates asked' do 62 | expect(Fastlane::Helper::FlutterHelper) 63 | .to receive(:flutter_installed?) 64 | .and_return(true) 65 | 66 | path = Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 67 | lane :test do 68 | flutter_bootstrap(flutter_auto_upgrade: false) 69 | end 70 | FASTLANE 71 | 72 | expect(path).to eq('/tmp/flutter') 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/flutter_build_action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Fastlane::Actions::FlutterBuildAction do 4 | describe '#run' do 5 | after do 6 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 7 | lane :test do 8 | lane_context[SharedValues::FLUTTER_OUTPUT] = nil 9 | lane_context[SharedValues::BUILD_NUMBER] = nil 10 | lane_context[SharedValues::VERSION_NUMBER] = nil 11 | end 12 | FASTLANE 13 | 14 | ENV['GYM_SCHEME'] = nil 15 | end 16 | 17 | describe 'parses' do 18 | it 'output from "flutter build"' do 19 | expect(Fastlane::Helper::FlutterHelper) 20 | .to receive(:flutter).and_yield(*successful_flutter(<<-BUILD_LOG)) 21 | Running task :assembleDebug... 22 | Searching for the ring... 23 | Built path/to/app-debug.apk. 24 | BUILD_LOG 25 | value = Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 26 | lane :test do 27 | flutter_build(build: 'apk') 28 | end 29 | FASTLANE 30 | # Prepend "fastlane" because that's what runner will do. 31 | expect(value).to eq(File.join(Dir.pwd, 'fastlane/path/to/app-debug.apk')) 32 | end 33 | 34 | it 'multiple outputs from "flutter build"' do 35 | expect(Fastlane::Helper::FlutterHelper) 36 | .to receive(:flutter).and_yield(*successful_flutter(<<-BUILD_LOG)) 37 | Running task :assembleDebug... 38 | Searching for the ring... 39 | Built path/to/app-armv7-debug.apk. 40 | Built path/to/app-x86-debug.apk. 41 | BUILD_LOG 42 | value = Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 43 | lane :test do 44 | flutter_build(build: 'apk') 45 | end 46 | FASTLANE 47 | expect(value).to eq( 48 | [ 49 | File.join(Dir.pwd, 'fastlane/path/to/app-armv7-debug.apk'), 50 | File.join(Dir.pwd, 'fastlane/path/to/app-x86-debug.apk') 51 | ] 52 | ) 53 | end 54 | end 55 | 56 | describe 'command line' do 57 | it 'apk debug' do 58 | expect(Fastlane::Helper::FlutterHelper) 59 | .to receive(:flutter) 60 | .with('build', 'apk', '--debug') 61 | .and_yield(*successful_flutter('')) 62 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 63 | lane :test do 64 | flutter_build(build: 'apk', debug: true) 65 | end 66 | FASTLANE 67 | end 68 | 69 | it 'build_name / build_number' do 70 | expect(Fastlane::Helper::FlutterHelper) 71 | .to receive(:flutter) 72 | .with( 73 | 'build', 'apk', '--debug', 74 | '--build-number', '456', 75 | '--build-name', '1.2.3' 76 | ) 77 | .and_yield(*successful_flutter('')) 78 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 79 | lane :test do 80 | flutter_build(build: 'apk', debug: true, 81 | build_name: '1.2.3', 82 | build_number: 456) 83 | end 84 | FASTLANE 85 | end 86 | 87 | it 'build_name / build_number from lane context' do 88 | expect(Fastlane::Helper::FlutterHelper) 89 | .to receive(:flutter) 90 | .with( 91 | 'build', 'apk', 92 | '--build-number', '654', 93 | '--build-name', '3.2.1' 94 | ) 95 | .and_yield(*successful_flutter('')) 96 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 97 | lane :test do 98 | lane_context[SharedValues::BUILD_NUMBER] = 654 99 | lane_context[SharedValues::VERSION_NUMBER] = '3.2.1' 100 | flutter_build(build: 'apk') 101 | end 102 | FASTLANE 103 | end 104 | 105 | it 'build type from fastlane platform' do 106 | expect(Fastlane::Helper::FlutterHelper) 107 | .to receive(:flutter) 108 | .with('build', 'ios') 109 | .and_yield(*successful_flutter('')) 110 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 111 | default_platform(:ios) 112 | lane :test do 113 | flutter_build 114 | end 115 | FASTLANE 116 | end 117 | 118 | it 'embeds build_args' do 119 | expect(Fastlane::Helper::FlutterHelper) 120 | .to receive(:flutter) 121 | .with('build', 'apk', '--debug', '--split-per-abi') 122 | .and_yield(*successful_flutter('')) 123 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 124 | lane :test do 125 | flutter_build(build: 'apk', build_args: %w(--debug --split-per-abi)) 126 | end 127 | FASTLANE 128 | end 129 | end 130 | 131 | describe 'gym environment' do 132 | it 'parses "-flavor ..." into GYM_SCHEME' do 133 | expect(Fastlane::Helper::FlutterHelper) 134 | .to receive(:flutter) 135 | .and_yield(*successful_flutter('')) 136 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 137 | default_platform(:ios) 138 | lane :test do 139 | flutter_build(build_args: %w(-flavor paidApp)) 140 | end 141 | FASTLANE 142 | expect(ENV['GYM_SCHEME']).to eq('paidApp') 143 | end 144 | 145 | it 'parses "--flavor=..." into GYM_SCHEME' do 146 | expect(Fastlane::Helper::FlutterHelper) 147 | .to receive(:flutter) 148 | .and_yield(*successful_flutter('')) 149 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 150 | default_platform(:ios) 151 | lane :test do 152 | flutter_build(build_args: %w(--flavor=freeApp)) 153 | end 154 | FASTLANE 155 | expect(ENV['GYM_SCHEME']).to eq('freeApp') 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/flutter_generate_action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Fastlane::Actions::FlutterGenerateAction do 4 | describe '#run' do 5 | before do 6 | expect(Fastlane::Helper::FlutterHelper) 7 | .to receive(:flutter) 8 | .with('packages', 'get') 9 | end 10 | 11 | it 'just passes when there are no known flags' do 12 | expect(Fastlane::Helper::FlutterHelper) 13 | .to receive(:dev_dependency?) 14 | .with('intl_translation'). 15 | # Once for verifying arguments, once for running the action. 16 | twice 17 | .and_return(false) 18 | 19 | expect(Fastlane::Helper::FlutterHelper) 20 | .to receive(:dev_dependency?) 21 | .with('build_runner') 22 | .and_return(false) 23 | 24 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 25 | lane :test do 26 | flutter_generate() 27 | end 28 | FASTLANE 29 | end 30 | 31 | describe 'coverage_all_imports' do 32 | before do 33 | expect(Fastlane::Helper::FlutterHelper) 34 | .to receive(:dev_dependency?) 35 | .with('intl_translation'). 36 | # Once for verifying arguments, once for running the action. 37 | twice 38 | .and_return(false) 39 | expect(Fastlane::Helper::FlutterHelper) 40 | .to receive(:dev_dependency?) 41 | .with('build_runner') 42 | .and_return(false) 43 | end 44 | 45 | it 'generates the file' do 46 | expect(Dir) 47 | .to receive(:[]) 48 | .with('lib/**/*.dart') 49 | .and_return( 50 | %w[ 51 | lib/main.dart 52 | lib/bad_imports.dart 53 | lib/built_list_of_things.g.dart 54 | ] 55 | ) 56 | expect(Fastlane::Helper::FlutterHelper) 57 | .to receive(:pub_package_name) 58 | .at_least(:once) 59 | .and_return('my_package') 60 | expect(File) 61 | .to receive(:read) 62 | .with('lib/main.dart', 4096) 63 | .and_return(<<-DART) 64 | import 'package:my_package/bad_imports.dart'; 65 | void main() {} 66 | DART 67 | expect(File) 68 | .to receive(:read) 69 | .with('lib/bad_imports.dart', 4096) 70 | .and_return(<<-DART) 71 | import './main.dart'; 72 | DART 73 | expect(File) 74 | .to receive(:write) 75 | .with('test/all_imports_for_coverage_test.dart', include(<<~DART)) 76 | import '../lib/bad_imports.dart'; 77 | import 'package:my_package/main.dart'; 78 | 79 | void main() {} 80 | DART 81 | 82 | Fastlane::FastFile.new.parse(<<-FASTLANE).runner.execute(:test) 83 | lane :test do 84 | flutter_generate(coverage_all_imports: true) 85 | end 86 | FASTLANE 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/flutter_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | describe Fastlane::Helper::FlutterHelper do 6 | describe 'flutter_sdk_root detects "flutter" path' do 7 | before do 8 | # Make sure there's no "flutter" in PATH. 9 | ENV['PATH'] = ENV['PATH'].gsub('flutter', '__removed__') 10 | ENV['FLUTTER_ROOT'] = nil 11 | # Remove ".flutter" pinned version in case it was installed by e2e. 12 | FileUtils.rm_rf('.flutter') 13 | 14 | # A terrible hack to reset the cache. 15 | described_class.instance_variable_set(:@flutter_sdk_root, 16 | nil) 17 | end 18 | 19 | it 'with FLUTTER_ROOT' do 20 | Dir.mktmpdir('fastlane-plugin-flutter-spec-') do |d| 21 | ENV['FLUTTER_ROOT'] = d 22 | expect(described_class.flutter_sdk_root) 23 | .to eq(d) 24 | end 25 | end 26 | 27 | it 'with "flutter" executable in PATH' do 28 | flutter_root = Dir.mktmpdir('fastlane-plugin-flutter-spec-') 29 | begin 30 | flutter_bin = File.join(flutter_root, 'bin') 31 | Dir.mkdir(flutter_bin) 32 | flutter_executable = File.join(flutter_bin, 'flutter') 33 | File.write(flutter_executable, 'echo') 34 | File.chmod(0o755, flutter_executable) 35 | File.write("#{flutter_executable}.bat", 'echo') 36 | ENV['PATH'] = [ENV['PATH'], flutter_bin].join(File::PATH_SEPARATOR) 37 | expect(described_class.flutter_sdk_root) 38 | .to eq(flutter_root) 39 | ensure 40 | FileUtils.rm_rf(flutter_root) 41 | end 42 | end 43 | 44 | it 'without hints' do 45 | expect(described_class.flutter_sdk_root) 46 | .to eq(File.join(Dir.pwd, '.flutter')) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) 4 | 5 | require 'simplecov' 6 | 7 | unless ENV['CODECOV_TOKEN'].to_s.empty? 8 | require 'codecov' 9 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 10 | warn('SimpleCov formatter set to Codecov') 11 | end 12 | SimpleCov.start 13 | 14 | # This module is only used to check the environment is currently a testing env 15 | module SpecHelper 16 | end 17 | 18 | require 'fastlane' # to import the Action super class 19 | require 'fastlane/plugin/flutter' # import the actual plugin 20 | 21 | Fastlane.load_actions # load other actions (in case your plugin calls other actions or shared values) 22 | 23 | def successful_flutter(output) 24 | status = double 25 | allow(status).to receive(:success?).and_return(true) 26 | [status, output] 27 | end 28 | --------------------------------------------------------------------------------