Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active April 20, 2022 16:47
Show Gist options
  • Save PlugFox/eebbff88578d0d980ad3e7b44702d2a6 to your computer and use it in GitHub Desktop.
Save PlugFox/eebbff88578d0d980ad3e7b44702d2a6 to your computer and use it in GitHub Desktop.
How to build flutter app totally without internet with artifactory

Какие критерии учитываются при добавлении пакетов?

Безопасность вашего сервиса прямо зависит от безопасности используемых в нём внешних зависимостей (библиотек, модулей, пакетов и т.д.). Поэтому при добавлении такой зависимости необходимо следовать следующим принципам:

  • Количество активных майнтейнеров. Должно быть > 3
  • Количество звёзд на GitHub. Должно быть > 30
  • Дата последнего релиза. Должно быть не позже 180 дней назад. То есть проект должен быть активным с т.з. разработки, а не заброшенным
  • Есть ли незакрытые известные уязвимости (смотрим в security.snyk.io )
  • Дата релиза проекта, который затаскиваем внутрь. Он должен быть до 20.02.2022. Исключение для исправления уязвимостей.

Получение списка зависимостей

❗ Зависимости желательно получать в докер контейнере используемом для сборки.
❗ Зависимости получать после успешной сборки приложения.
❗ Помимо зависимостей андроида также нужны зависимости прописаные в блоке buildscript.dependencies в файле android/build.gradle.
❗ И зависимости этих зависимостей и тп и тд.

1️⃣ Flutter:

flutter pub outdated --transitive --dev-dependencies --dependency-overrides > flutter_dependencies.txt 2>&1

2️⃣ Gradle:

gradle buildEnvironment > gradle_dependencies.txt 2>&1

3️⃣ Android:

cd android; ./gradlew app:androidDependencies > android_dependencies.txt 2>&1

❗ Также все зависимости можно собрать с помощью скрипта: dart run tools/dependencies.dart > dependencies.yaml

Откуда берутся flutter engine под различные архитектуры

Исходя из файла flutter.gradle:

private static final String DEFAULT_MAVEN_HOST = "https://storage.googleapis.com";
...
String hostedRepository = System.env.FLUTTER_STORAGE_BASE_URL ?: DEFAULT_MAVEN_HOST
String repository = useLocalEngine()
    ? project.property('local-engine-repo')
    : "$hostedRepository/download.flutter.io"
rootProject.allprojects {
    repositories {
        maven {
            url repository
        }
    }
}

Флатер энжайны под различные процессорные архитектуры тянутся с storage.googleapis.com/download.flutter.io можно переопределить host используя переменную окружения FLUTTER_STORAGE_BASE_URL
или установить свойство проекта local-engine-repo для указания локального хранилища. Ну или подменить сам flutter.gradle в докер образе для сборки (я выбрал этот путь).

Как прописать зависимости

Сервисы используемые для кэширования зависимостей:

Образ для сборки строится на базе:

1️⃣ Flutter:

В pubspec.yaml должны быть прописаны явно все dependencies и dev_dependencies, в тч и транзитивные. Зависимости должны быть указаны явно: some_package: 1.2.3+4. Установить переменную окружения: export PUB_HOSTED_URL="https://artifactory.domain.tld/artifactory/api/pub/pub.dev"

❗ Также можно заставить работать паб через прокси сервис: export https_proxy=username:password@hostname:port

2️⃣ Gradle:

В файле android/gradle/wrapper/gradle-wrapper.properties прописать: https://artifactory.domain.tld/artifactory/gradle-gradle-remote/gradle-6.7-all.zip

❗ Следующий шаг в рамках этого гайда не нужен. При его использовании будет предполагаться, что все зависимости у вас уже будут зашиты в докер образе.

Если хотите заставить работать gradle в оффлайне, необходимо изменить последнюю строку <project>/android/gradlew при сборке на:

exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" \
          org.gradle.wrapper.GradleWrapperMain "$@" --offline

3️⃣ Android:

В файле android/build.gradle должно быть прописаны локальные репозтории (в двух местах):

buildscript {
    ext.kotlin_version = '1.6.10'
    repositories {
        // ИСПОЛЬЗОВАТЬ ПУБЛИЧНЫЕ РЕПОЗИТОРИИ
        //google()
        //mavenCentral()
        //maven { url 'https://developer.huawei.com/repo/' }

        // ИСПОЛЬЗОВАТЬ РЕПОЗИТОРИЙ ОЗОНА
        maven { url 'https://nexus.domain.tld/repository/repo/' }
    }
    ...
allprojects {
    repositories {
        // ИСПОЛЬЗОВАТЬ ПУБЛИЧНЫЕ РЕПОЗИТОРИИ
        //google()
        //mavenCentral()
        //maven { url 'https://developer.huawei.com/repo/' } // HUAWEI

        // ИСПОЛЬЗОВАТЬ РЕПОЗИТОРИЙ ОЗОНА
        maven { url 'https://nexus.domain.tld/repository/repo/' }
    }
}

4️⃣ Flutter Android Plugins:

❗ Со всем этим есть ньюанс: Flutter при flutter pub get генерирует .flutter-plugins-dependencies
В котором собраны все нейтивные плагины.
В settings.gradle они подключаются через apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" что в свою очередь перебирает все андроид зависимости и подключает их через:

include ":${androidPlugin.name}"
project(":${androidPlugin.name}").projectDir = pluginDirectory

При таком подключении зависимости обладают собственными репозиториями (например google и mavenCentral) - их также нужно переопределить. Это можно попробывать сделать через

  1. Переопределение в settings.gradle и init.gradle с помощью pluginManagement и dependencyResolutionManagement

  2. Более радикальным путем, сделав flutter pub get, а затем распарсив .flutter-plugins-dependencies, перебирая андроид зависимости и заменяя скриптом секции репозиториев на локальные. Смотри: tools/replace_android_repositories.dart.

5️⃣ Flutter Engine:

В dockerfile'е нужно подменить flutter.gradle: COPY --chown=101:101 ./dockerfiles/flutter.gradle /opt/flutter/packages/flutter_tools/gradle/flutter.gradle В котором прописано:

repositories {
    maven { url 'https://nexus.domain.tld/repository/repo/' }
}

или задать расположение flutter engine через System.env.FLUTTER_STORAGE_BASE_URL или через project.property('local-engine-repo')

6️⃣ Android SDK:

В контейнере должны быть установлены все зависимости Android SDK достаточные для сборки. Например: sdkmanager --sdk_root=${ANDROID_HOME} --install 'extras;google;instantapps' 'platforms;android-30' 'platforms;android-31' 'build-tools;29.0.2'

7️⃣ Crashlytics:

В файле android/app/build.gradle Google Service должно быть прописано над Crashlytics:

// Google Service
apply plugin: 'com.google.gms.google-services'
// Firebase Crashlytics plugin
apply plugin: 'com.google.firebase.crashlytics'

А также отключено mappingFileUploadEnabled в секции buildTypes:

    buildTypes {
        release {
            ...
            firebaseCrashlytics {
                mappingFileUploadEnabled = false
            }
        }
        debug {
            ...
            firebaseCrashlytics {
                mappingFileUploadEnabled = false
            }
        }
    }

Что делать если падает сборка

  1. в пайплайне прописать сбор артифакта получения зависимостей time timeout 300 flutter pub get --suppress-analytics --verbose >> pub_get.txt 2>&1 (по аналогии поступить и с flutter build apk)
  2. скачивать и открывать архив артефактов упавшего пайплайна
  3. открывать файл pub_get.txt (по аналогии и с build.txt)
  4. смотреть на чем оно отвалилось с ошибкой таймаута (конец файла)
  5. сверять зависимости и их версии с одобренным списком артифактори или нексуса
  6. прописывать версии зависимостей и транзитивных зависимостей явно в pubspec.yaml в соответсвии с кэшем
  7. репозитории подключаемых андроид плагинов из флатера также должны быть подменены на локальные

❗ Проверить наличие флаттер зависимостей в репозитории можно по адресу: artifactory.domain.tld
❗ Проверить наличие андроид зависимостей в репозитории можно по адресу: nexus.domain.tld

# You can check artifactory upload with this dockerfile
#
# Build
# docker build --file dart_artifactory.dockerfile --tag dart-artifactory .
#
# Run
# docker run -it --rm --name check dart-artifactory /bin/bash -c "dart pub get -v"
FROM dart:stable AS production
ENV PUB_HOSTED_URL="https://artifactory.domain.tld/artifactory/api/pub/pub.dev"
RUN set -eux; mkdir -p /app \
&& echo \
"name: artifactory_dart\n"\
"description: JFrog Artifactory support\n"\
"publish_to: 'none'\n"\
"version: 0.0.1+1\n"\
"environment:\n"\
" sdk: '>=2.16.1 <3.0.0'\n"\
"dependencies:\n"\
" bloc: 7.2.1\n"\
" meta: 1.7.0\n"\
> /app/pubspec.yaml
USER root
WORKDIR /app
SHELL [ "/bin/bash", "-c" ]
CMD [ "dart", "pub", "get", "-v" ]
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io' as io;
/// Получение данных о зависимостях
/// dart run tools/dependencies.dart > dependencies.yaml
void main() => Future<void>(() async {
final flutter = await _flutterDependencies();
final buffer = StringBuffer('# Collected with `dart run tools/dependencies.dart > dependencies.yaml`')
..writeln()
..writeln('# Flutter has ${flutter.length} dependencies')
..writeln('flutter:')
..writeAll(flutter.map<String>((e) => ' $e'), '\n')
..writeln()
..writeln();
final gradle = await _gradleDependencies();
buffer
..writeln('# Gradle has ${gradle.length} dependencies')
..writeln('gradle:')
..writeAll(gradle.map<String>((e) => ' - "$e"'), '\n')
..writeln()
..writeln();
final android = await _androidDependencies();
buffer
..writeln('# Android has ${android.length} dependencies')
..writeln('android:')
..writeAll(android.map<String>((e) => ' - "$e"'), '\n')
..writeln()
..writeln();
io.stdout.writeln(buffer.toString());
io.exit(0);
});
Future<List<String>> _flutterDependencies() async {
final regExp = RegExp(
r'^(?<package>[\w]+)\s+[\*]*(?<version>[\.\-\+\d]+)\s+[\*\.\+\-\d]+\s+[\*\.\+\-\d]+\s+[\*\.\+\-\d]+',
multiLine: true,
caseSensitive: false,
unicode: false,
dotAll: false,
);
final proc = await io.Process.start(
'flutter',
<String>[
'pub',
'outdated',
'--transitive',
'--dev-dependencies',
'--dependency-overrides',
],
);
// ignore: unawaited_futures
proc.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).forEach(print);
return proc.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.map<RegExpMatch?>(regExp.firstMatch)
.where((e) => e != null)
.map<String>((e) => '${e!.namedGroup('package')}: ${e.namedGroup('version')}')
.map<String>((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList()
.then<List<String>>((e) => e.toSet().toList(growable: false));
}
Future<List<String>> _gradleDependencies() async {
final regExp = RegExp(
r'^[\+\|\-\s\\]+(?<dep>[\w\d\.\:\-]{3,}\:[\d\.]+[\-\w\d\.]*)',
multiLine: true,
caseSensitive: false,
unicode: false,
dotAll: false,
);
final s = io.Platform.pathSeparator;
final proc = await io.Process.start(
'gradle',
<String>[
'buildEnvironment',
],
workingDirectory: '${io.Directory.current.absolute.path}${s}android',
);
// ignore: unawaited_futures
proc.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).forEach(print);
return proc.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.skipWhile((e) => e != '> Task :buildEnvironment')
.map<RegExpMatch?>(regExp.firstMatch)
.where((e) => e != null)
.map<String>((e) => e!.namedGroup('dep') ?? '')
.map<String>((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList()
.then<List<String>>((e) => e.toSet().toList(growable: false));
}
Future<List<String>> _androidDependencies() async {
final s = io.Platform.pathSeparator;
final proc = await io.Process.start(
io.Platform.isWindows ? './gradlew.bat' : './gradlew',
<String>[
'app:androidDependencies',
],
workingDirectory: '${io.Directory.current.absolute.path}${s}android',
);
// ignore: unawaited_futures
proc.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).forEach(print);
return proc.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.where((e) => e.startsWith('+--- ') || e.startsWith(r'\--- '))
.map<String>((e) => e.substring(4))
.map<String>((e) => e.trim())
.where((e) => e.isNotEmpty)
.where((e) => !(e.startsWith(':') || e.startsWith('/')))
.map<String>((e) => (e.endsWith('@aar') || e.endsWith('@jar')) ? e.substring(0, e.length - 4) : e)
.toList()
.then<List<String>>((e) => e.toSet().toList(growable: false));
}
# ------------------------------------------------------
# Dockerfile
# ------------------------------------------------------
# image: gitlab-registry.domain.tld/mobile/app/flutter
# authors: plugfox@gmail.com
# license: MIT
# ------------------------------------------------------
ARG VERSION="stable"
FROM plugfox/flutter:${VERSION}-android-warmed as build
ARG VERSION
USER root
WORKDIR /
ENV PUB_CACHE="/var/tmp/.pub_cache" \
PUB_HOSTED_URL="https://artifactory.domain.tld/artifactory/api/pub/pub.dev"
# Copy adb keys if needed
#COPY ./adbkey /root/.android/adbkey
#COPY ./adbkey.pub /root/.android/adbkey.pub
# If you need replace it with patched flutter.gradle
COPY --chown=101:101 ./dockerfiles/flutter.gradle /opt/flutter/packages/flutter_tools/gradle/flutter.gradle
# Get some usefull packages
#RUN dart pub global activate stagehand \
# && dart pub global activate grinder \
# && dart pub global activate cider \
# && dart pub global activate pana \
# && dart pub global activate flutter_gen \
# && dart pub global activate dart_code_metrics
# Install linux dependency and utils
RUN set -eux; \
# Operating system dependencies:
echo "https://dl-cdn.alpinelinux.org/alpine/edge/main" > /etc/apk/repositories \
&& echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
&& echo "https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories \
&& apk update \
&& apk --no-cache add sqlite sqlite-dev lcov \
&& rm -rf /var/lib/apt/lists/* /var/cache/apk/* \
/usr/share/man/* /usr/share/doc \
# Android dependencies:
&& mkdir -p /root/.android \
&& echo "count=0" > /root/.android/repositories.cfg \
&& sdkmanager --sdk_root=${ANDROID_HOME} --install 'platforms;android-30' 'platforms;android-31' 'build-tools;29.0.2' \
# Flutter dependencies:
&& dart --disable-analytics \
&& flutter config --no-analytics --no-color \
&& flutter doctor \
# Set default git user
#&& git config --global --add safe.directory /opt/flutter \
&& git config --global user.email "email@domain.tld" \
&& git config --global user.name "First Last" \
&& git config --global credential.helper store
LABEL maintainer="plugfox@gmail.com" \
authors="plugfox" \
description="Linux image for Flutter & Dart with helpful utils."
SHELL [ "/bin/bash", "-c" ]
CMD [ "flutter", "doctor" ]
#ENTRYPOINT [ ]
allprojects {
repositories {
maven { url 'https://nexus.domain.tld/repository/repo/' }
}
}
// ignore_for_file: avoid_print, prefer_single_quotes
import 'dart:convert';
import 'dart:io' as io;
import 'package:path/path.dart' as p;
void main() => Future<void>(() async {
final plugins = await _getFlutterPlugins(pubGet: false);
final androidPlugins = ((plugins['plugins'] as Map<String, Object?>)['android'] as Iterable<Object?>?)
?.cast<Map<String, Object?>>()
.map<AndroidPlugin>(AndroidPlugin.fromJson)
.toList(growable: false);
if (androidPlugins == null) {
print('No android plugins found');
io.exit(0);
}
final gradles = androidPlugins
.map<Tuple<AndroidPlugin, io.File>>((e) => Tuple(e, io.File(p.join(e.path, 'android', 'build.gradle'))))
.where((e) => e.second.existsSync())
.toList(growable: false);
for (final tuple in gradles) {
await _replaceRepositories(tuple.first, tuple.second);
}
});
/// Получить путь к файлу .flutter-plugins-dependencies
Future<Map<String, Object?>> _getFlutterPlugins({bool pubGet = true}) async {
var projectDir = io.Directory.current;
if (!projectDir.listSync().any((e) => e is io.File && e.path.endsWith('pubspec.yaml'))) {
projectDir = projectDir.parent;
if (!projectDir.listSync().any((e) => e is io.File && e.path.endsWith('pubspec.yaml'))) {
throw Exception('Could not find pubspec.yaml');
}
}
if (pubGet) {
final result = await io.Process.run(
'flutter',
<String>[
'pub',
'get',
],
workingDirectory: projectDir.path,
);
if (result.exitCode != 0) {
throw Exception('flutter pub get failed');
}
}
final dependenciesFile =
projectDir.listSync().firstWhere((e) => e is io.File && e.path.endsWith('.flutter-plugins-dependencies')) as io.File;
final dependenciesRaw =
await dependenciesFile.openRead().transform<String>(utf8.decoder).transform<String>(const LineSplitter()).join('\n');
return jsonDecode(dependenciesRaw) as Map<String, Object?>;
}
abstract class Plugin {
Plugin(
this.name,
this.path,
this.dependencies,
);
final String name;
final String path;
final List<String> dependencies;
@override
String toString() => name;
}
Future<void> _replaceRepositories(AndroidPlugin plugin, io.File gradle) async {
final buffer = StringBuffer();
var overrideMode = false;
var counter = 0;
await gradle.openRead().transform<String>(utf8.decoder).transform<String>(const LineSplitter()).forEach((e) {
final line = e.trim();
if (overrideMode) {
if (line == '}') {
buffer
//..writeln(" google()")
//..writeln(" mavenCentral()")
..writeln(" maven { url 'https://nexus.domain.tld/repository/repo/' }")
..writeln(e);
overrideMode = false;
counter++;
}
} else {
buffer.writeln(e);
if (line.startsWith('repositories') && line.endsWith('{')) {
overrideMode = true;
}
}
});
if (overrideMode) {
buffer.writeln('}');
}
await gradle.writeAsString(
buffer.toString(),
mode: io.FileMode.write,
);
if (counter > 0) {
print('Перезаписали $counter репозиториев для "${plugin.name}" в "${p.basename(gradle.path)}"');
}
/*
final sink = gradle.openWrite(mode: io.FileMode.write);
final data = await gradle.readAsString();
final regExp = RegExp(
r'^(?<repos>[\s]*repositories[\s]*\{[\s]*\n(?:\s*(?:(?:\w+.*)|\/\/.*|\s*)\n)+^[\s]*\})[\s]*\n',
multiLine: true,
caseSensitive: false,
unicode: false,
dotAll: false,
);
final matches = regExp.allMatches(data);
for (final e in matches) {
final group = e.namedGroup('repos');
print('${e.start} : ${e.end}');
print(group);
}
await sink.close();
*/
}
class AndroidPlugin extends Plugin {
AndroidPlugin({
required String name,
required String path,
List<String> dependencies = const <String>[],
}) : super(name, path, dependencies);
factory AndroidPlugin.fromJson(Map<String, Object?> json) => AndroidPlugin(
name: json['name'] as String,
path: json['path'] as String,
dependencies: (json['dependencies'] as List<Object?>).whereType<String>().toList(),
);
}
class Tuple<A, B> {
Tuple(this.first, this.second);
final A first;
final B second;
}
pluginManagement {
repositories {
//println("REPOS: ${names}")
clear()
maven { url 'https://nexus.domain.tld/repository/repo/' }
//println("REPOS: ${names}")
}
}
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
// Override all repositories with self hosted nexus
def overrideRepos = true
if (overrideRepos) {
gradle.settingsEvaluated {
pluginManagement {
repositories {
clear()
maven { url 'https://nexus.domain.tld/repository/repo/' }
//println("REPOS: ${names}")
}
}
}
gradle.allprojects { project ->
//print('Project path: ')
//println(project.path.toString())
//def dependencies = project.buildscript.dependencies
project.buildscript.repositories { repos ->
// println "${project.path} -> ${repo.name} -> ${repo.url}"
repos.clear()
repos.add(maven { url 'https://nexus.domain.tld/repository/repo/' }) // mavenCentral()
println("! Override buildscript repositories for ${project.path}")
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories{
maven { url 'https://nexus.domain.tld/repository/repo/' }
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
// Fixed https://github.com/flutter/flutter/issues/97729 by adding the lines below
def plugins = new Properties()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}
plugins.each { name, path ->
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
include ":$name"
project(":$name").projectDir = pluginDirectory
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment