[flutter_tools][web] Add support for web app manifests and arbitrary resource files (from web/) (#48316)


diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart
index adcbc2f..5fb6664 100644
--- a/dev/bots/analyze.dart
+++ b/dev/bots/analyze.dart
@@ -761,6 +761,13 @@
   // (also used by a few examples)
   Hash256(0xD29D4E0AF9256DC9, 0x2D0A8F8810608A5E, 0x64A132AD8B397CA2, 0xC4DDC0B1C26A68C3),
 
+  // packages/flutter_tools/templates/app/web/icons/Icon-192.png.copy.tmpl
+  // examples/flutter_gallery/web/icons/Icon-192.png
+  Hash256(0x3DCE99077602F704, 0x21C1C6B2A240BC9B, 0x83D64D86681D45F2, 0x154143310C980BE3),
+
+  // packages/flutter_tools/templates/app/web/icons/Icon-512.png.copy.tmpl
+  // examples/flutter_gallery/web/icons/Icon-512.png
+  Hash256(0xBACCB205AE45f0B4, 0x21BE1657259B4943, 0xAC40C95094AB877F, 0x3BCBE12CD544DCBE),
 
   // GALLERY ICONS
 
@@ -994,7 +1001,7 @@
   assert(
     _grandfatheredBinaries
       .expand<int>((Hash256 hash) => <int>[hash.a, hash.b, hash.c, hash.d])
-      .reduce((int value, int element) => value ^ element) == 0x39A050CD69434936 // Please do not modify this line.
+      .reduce((int value, int element) => value ^ element) == 0xBFC18DE113B5AE8E // Please do not modify this line.
   );
   grandfatheredBinaries ??= _grandfatheredBinaries;
   if (!Platform.isWindows) { // TODO(ianh): Port this to Windows
diff --git a/examples/flutter_gallery/web/icons/Icon-192.png b/examples/flutter_gallery/web/icons/Icon-192.png
new file mode 100644
index 0000000..b749bfef
--- /dev/null
+++ b/examples/flutter_gallery/web/icons/Icon-192.png
Binary files differ
diff --git a/examples/flutter_gallery/web/icons/Icon-512.png b/examples/flutter_gallery/web/icons/Icon-512.png
new file mode 100644
index 0000000..88cfd48
--- /dev/null
+++ b/examples/flutter_gallery/web/icons/Icon-512.png
Binary files differ
diff --git a/examples/flutter_gallery/web/index.html b/examples/flutter_gallery/web/index.html
index ce6634c..69b9962 100644
--- a/examples/flutter_gallery/web/index.html
+++ b/examples/flutter_gallery/web/index.html
@@ -3,10 +3,21 @@
 Use of this source code is governed by a BSD-style license that can be
 found in the LICENSE file. -->
 <html>
-    <head>
-        <title>Flutter Gallery</title>
-    </head>
-    <body>
-        <script src="main.dart.js"></script>
-    </body>
+<head>
+  <meta charset="UTF-8">
+  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
+  <meta name="description" content="A demo app for Flutter's material design and cupertino widgets, as well as many other features of the Flutter SDK.">
+
+  <!-- iOS meta tags & icons -->
+  <meta name="apple-mobile-web-app-capable" content="yes">
+  <meta name="apple-mobile-web-status-bar-style" content="black">
+  <meta name="apple-mobile-web-app-title" content="Flutter Gallery">
+  <link rel="apple-touch-icon" href="/icons/Icon-192.png">
+
+  <title>Flutter Gallery</title>
+  <link rel="manifest" href="/manifest.json">
+</head>
+<body>
+  <script src="main.dart.js" type="application/javascript"></script>
+</body>
 </html>
diff --git a/examples/flutter_gallery/web/manifest.json b/examples/flutter_gallery/web/manifest.json
new file mode 100644
index 0000000..47be1a1
--- /dev/null
+++ b/examples/flutter_gallery/web/manifest.json
@@ -0,0 +1,23 @@
+{
+    "name": "flutter_gallery",
+    "short_name": "flutter_gallery",
+    "start_url": ".",
+    "display": "minimal-ui",
+    "background_color": "#0175C2",
+    "theme_color": "#0175C2",
+    "description": "A new Flutter project.",
+    "orientation": "portrait-primary",
+    "prefer_related_applications": false,
+    "icons": [
+        {
+            "src": "icons/Icon-192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "icons/Icon-512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ]
+}
diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart
index 2237175..5b5f2b8 100644
--- a/packages/flutter_tools/lib/src/build_system/targets/web.dart
+++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart
@@ -198,7 +198,7 @@
   }
 }
 
-/// Unpacks the dart2js compilation to a given output directory
+/// Unpacks the dart2js compilation and resources to a given output directory
 class WebReleaseBundle extends Target {
   const WebReleaseBundle();
 
@@ -214,18 +214,18 @@
   List<Source> get inputs => const <Source>[
     Source.pattern('{BUILD_DIR}/main.dart.js'),
     Source.pattern('{PROJECT_DIR}/pubspec.yaml'),
-    Source.pattern('{PROJECT_DIR}/web/index.html'),
   ];
 
   @override
   List<Source> get outputs => const <Source>[
     Source.pattern('{OUTPUT_DIR}/main.dart.js'),
-    Source.pattern('{OUTPUT_DIR}/index.html'),
   ];
 
   @override
   List<String> get depfiles => const <String>[
     'dart2js.d',
+    'flutter_assets.d',
+    'web_resources.d',
   ];
 
   @override
@@ -240,11 +240,30 @@
     }
     final Directory outputDirectory = environment.outputDir.childDirectory('assets');
     outputDirectory.createSync(recursive: true);
-    environment.projectDir
-      .childDirectory('web')
-      .childFile('index.html')
-      .copySync(globals.fs.path.join(environment.outputDir.path, 'index.html'));
     final Depfile depfile = await copyAssets(environment, environment.outputDir.childDirectory('assets'));
     depfile.writeToFile(environment.buildDir.childFile('flutter_assets.d'));
+
+    final Directory webResources = environment.projectDir
+      .childDirectory('web');
+    final List<File> inputResourceFiles = webResources
+      .listSync(recursive: true)
+      .whereType<File>()
+      .toList();
+
+    // Copy other resource files out of web/ directory.
+    final List<File> outputResourcesFiles = <File>[];
+    for (final File inputFile in inputResourceFiles) {
+      final File outputFile = globals.fs.file(globals.fs.path.join(
+        environment.outputDir.path,
+        globals.fs.path.relative(inputFile.path, from: webResources.path)));
+      if (!outputFile.parent.existsSync()) {
+        outputFile.parent.createSync(recursive: true);
+      }
+      inputFile.copySync(outputFile.path);
+      outputResourcesFiles.add(outputFile);
+    }
+    final Depfile resourceFile = Depfile(inputResourceFiles, outputResourcesFiles);
+    resourceFile.writeToFile(environment.buildDir.childFile('web_resources.d'));
+
   }
 }
diff --git a/packages/flutter_tools/templates/app/web/icons/Icon-192.png.copy.tmpl b/packages/flutter_tools/templates/app/web/icons/Icon-192.png.copy.tmpl
new file mode 100644
index 0000000..b749bfef
--- /dev/null
+++ b/packages/flutter_tools/templates/app/web/icons/Icon-192.png.copy.tmpl
Binary files differ
diff --git a/packages/flutter_tools/templates/app/web/icons/Icon-512.png.copy.tmpl b/packages/flutter_tools/templates/app/web/icons/Icon-512.png.copy.tmpl
new file mode 100644
index 0000000..88cfd48
--- /dev/null
+++ b/packages/flutter_tools/templates/app/web/icons/Icon-512.png.copy.tmpl
Binary files differ
diff --git a/packages/flutter_tools/templates/app/web/index.html.tmpl b/packages/flutter_tools/templates/app/web/index.html.tmpl
index 34621ea..dc50861 100644
--- a/packages/flutter_tools/templates/app/web/index.html.tmpl
+++ b/packages/flutter_tools/templates/app/web/index.html.tmpl
@@ -2,7 +2,17 @@
 <html>
 <head>
   <meta charset="UTF-8">
+  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
+  <meta name="description" content="{{description}}">
+
+  <!-- iOS meta tags & icons -->
+  <meta name="apple-mobile-web-app-capable" content="yes">
+  <meta name="apple-mobile-web-status-bar-style" content="black">
+  <meta name="apple-mobile-web-app-title" content="{{projectName}}">
+  <link rel="apple-touch-icon" href="/icons/Icon-192.png">
+
   <title>{{projectName}}</title>
+  <link rel="manifest" href="/manifest.json">
 </head>
 <body>
   <script src="main.dart.js" type="application/javascript"></script>
diff --git a/packages/flutter_tools/templates/app/web/manifest.json.tmpl b/packages/flutter_tools/templates/app/web/manifest.json.tmpl
new file mode 100644
index 0000000..fb18a4d
--- /dev/null
+++ b/packages/flutter_tools/templates/app/web/manifest.json.tmpl
@@ -0,0 +1,23 @@
+{
+    "name": "{{projectName}}",
+    "short_name": "{{projectName}}",
+    "start_url": ".",
+    "display": "minimal-ui",
+    "background_color": "#0175C2",
+    "theme_color": "#0175C2",
+    "description": "{{description}}",
+    "orientation": "portrait-primary",
+    "prefer_related_applications": false,
+    "icons": [
+        {
+            "src": "icons/Icon-192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "icons/Icon-512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ]
+}
diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
index b5c3b38..f3458b1 100644
--- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
+++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
@@ -31,6 +31,7 @@
     when(mockPlatform.isWindows).thenReturn(false);
     when(mockPlatform.isMacOS).thenReturn(true);
     when(mockPlatform.isLinux).thenReturn(false);
+    when(mockPlatform.environment).thenReturn(const <String, String>{});
 
     when(mockWindowsPlatform.isWindows).thenReturn(true);
     when(mockWindowsPlatform.isMacOS).thenReturn(false);
@@ -41,10 +42,11 @@
         ..createSync(recursive: true)
         ..writeAsStringSync('foo:lib/\n');
       PackageMap.globalPackagesPath = packagesFile.path;
+      globals.fs.currentDirectory.childDirectory('bar').createSync();
 
       environment = Environment(
         projectDir: globals.fs.currentDirectory.childDirectory('foo'),
-        outputDir: globals.fs.currentDirectory,
+        outputDir: globals.fs.currentDirectory.childDirectory('bar'),
         buildDir: globals.fs.currentDirectory,
         defines: <String, String>{
           kTargetFile: globals.fs.path.join('foo', 'lib', 'main.dart'),
@@ -77,6 +79,32 @@
     expect(generated, contains("import 'package:foo/main.dart' as entrypoint;"));
   }));
 
+  test('WebReleaseBundle copies dart2js output and resource files to output directory', () => testbed.run(() async {
+    final Directory webResources = environment.projectDir.childDirectory('web');
+    webResources.childFile('index.html')
+      ..createSync(recursive: true);
+    webResources.childFile('foo.txt')
+      ..writeAsStringSync('A');
+    environment.buildDir.childFile('main.dart.js').createSync();
+
+    await const WebReleaseBundle().build(environment);
+
+    expect(environment.outputDir.childFile('foo.txt')
+      .readAsStringSync(), 'A');
+    expect(environment.outputDir.childFile('main.dart.js')
+      .existsSync(), true);
+    expect(environment.outputDir.childDirectory('assets')
+      .childFile('AssetManifest.json').existsSync(), true);
+
+    // Update to arbitary resource file triggers rebuild.
+    webResources.childFile('foo.txt').writeAsStringSync('B');
+
+    await const WebReleaseBundle().build(environment);
+
+    expect(environment.outputDir.childFile('foo.txt')
+      .readAsStringSync(), 'B');
+  }));
+
   test('WebEntrypointTarget generates an entrypoint for a file outside of main', () => testbed.run(() async {
     environment.defines[kTargetFile] = globals.fs.path.join('other', 'lib', 'main.dart');
     await const WebEntrypointTarget().build(environment);