[simple_browser] Add simple browser

TESTED=ran on device

Change-Id: I1222a7e1ae38368316cbf0172a354f0c572575f1
diff --git a/bin/simple_browser/BUILD.gn b/bin/simple_browser/BUILD.gn
new file mode 100644
index 0000000..cfce1b5
--- /dev/null
+++ b/bin/simple_browser/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/fidl/fidl.gni")
+import("//topaz/runtime/flutter_runner/flutter_app.gni")
+
+flutter_app("simple_browser") {
+  main_dart = "lib/main.dart"
+
+  meta = [
+    {
+      path = rebase_path("meta/simple_browser.cmx")
+      dest = "simple_browser.cmx"
+    },
+  ]
+
+  package_name = "simple_browser"
+
+  manifest = "pubspec.yaml"
+
+  deps = [
+    "//third_party/dart-pkg/git/flutter/packages/flutter",
+    "//third_party/dart-pkg/pub/html_unescape",
+    "//third_party/dart-pkg/pub/http",
+    "//topaz/public/dart/fuchsia_logger",
+    "//topaz/public/dart/fuchsia_modular",
+    "//topaz/public/dart/fuchsia_scenic_flutter",
+    "//topaz/public/dart/widgets:lib.widgets",
+    "//topaz/runtime/chromium:chromium.web",
+    "//topaz/public/lib/webview",
+  ]
+}
diff --git a/bin/simple_browser/OWNERS b/bin/simple_browser/OWNERS
new file mode 100644
index 0000000..3b6fdfc
--- /dev/null
+++ b/bin/simple_browser/OWNERS
@@ -0,0 +1,4 @@
+set noparent
+
+ahetzroni@google.com
+miguelfrde@google.com
diff --git a/bin/simple_browser/README.md b/bin/simple_browser/README.md
new file mode 100644
index 0000000..c668ae9
--- /dev/null
+++ b/bin/simple_browser/README.md
@@ -0,0 +1,4 @@
+# Browser
+
+Minimal traditional web browser with navigation and a text field for
+entering the URL.
diff --git a/bin/simple_browser/analysis_options.yaml b/bin/simple_browser/analysis_options.yaml
new file mode 100644
index 0000000..73da07f
--- /dev/null
+++ b/bin/simple_browser/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+include: ../analysis_options.yaml
diff --git a/bin/simple_browser/lib/app.dart b/bin/simple_browser/lib/app.dart
new file mode 100644
index 0000000..b027e39
--- /dev/null
+++ b/bin/simple_browser/lib/app.dart
@@ -0,0 +1,56 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:fuchsia_services/services.dart';
+import 'package:fuchsia_scenic_flutter/child_view.dart' show ChildView;
+import 'package:webview/webview.dart';
+import 'src/blocs/browser_bloc.dart';
+import 'src/widgets/navigation_bar.dart';
+
+class App extends StatefulWidget {
+  @override
+  State<App> createState() => AppState();
+}
+
+class AppState extends State<App> {
+  BrowserBloc _browserBloc;
+  ChromiumWebView _webView;
+
+  AppState() {
+    _webView = ChromiumWebView(
+      StartupContext.fromStartupInfo().environmentServices,
+    );
+    _browserBloc = BrowserBloc(webView: _webView);
+  }
+
+  @override
+  void dispose() {
+    _browserBloc.dispose();
+    _webView.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'Browser',
+      home: Scaffold(
+        backgroundColor: Colors.grey,
+        body: Container(
+          child: Column(
+            children: <Widget>[
+              NavigationBar(bloc: _browserBloc),
+              Expanded(
+                child: ChildView(
+                  connection: _webView.childViewConnection,
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/bin/simple_browser/lib/main.dart b/bin/simple_browser/lib/main.dart
new file mode 100644
index 0000000..1136e4e
--- /dev/null
+++ b/bin/simple_browser/lib/main.dart
@@ -0,0 +1,13 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:fuchsia_logger/logger.dart';
+
+import 'app.dart';
+
+void main() {
+  setupLogger(name: 'Browser');
+  runApp(App());
+}
diff --git a/bin/simple_browser/lib/src/blocs/browser_bloc.dart b/bin/simple_browser/lib/src/blocs/browser_bloc.dart
new file mode 100644
index 0000000..507ae47
--- /dev/null
+++ b/bin/simple_browser/lib/src/blocs/browser_bloc.dart
@@ -0,0 +1,135 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io';
+import 'dart:math';
+import 'package:fuchsia_logger/logger.dart';
+import 'package:fidl_chromium_web/fidl_async.dart' as web;
+import 'package:meta/meta.dart';
+import 'package:webview/webview.dart';
+import '../models/browse_action.dart';
+
+// Business logic for the browser.
+// Sinks:
+//   BrowseAction: a browsing action - url request, prev/next page, etc.
+// Streams:
+//   Url: streams the url in case of an in page navigation.
+class BrowserBloc extends web.NavigationEventObserver {
+  final ChromiumWebView webView;
+
+  // State storage for browsing with back/forward buttons.
+  // Stores the browsing history.
+  final _urlList = <String>[];
+  // Stores the current location of the browser WRT history.
+  int _urlListHead = -1;
+  // True when the most recent navigation used back/forward buttons, false
+  // otherwise.
+  bool _navigationOngoing = false;
+
+  // Streams
+  final _urlController = StreamController<String>.broadcast();
+  Stream<String> get url => _urlController.stream;
+  final _forwardController = StreamController<bool>.broadcast();
+  Stream<bool> get forwardState => _forwardController.stream;
+  final _backController = StreamController<bool>.broadcast();
+  Stream<bool> get backState => _backController.stream;
+
+  // Sinks
+  final _browseActionController = StreamController<BrowseAction>();
+  Sink<BrowseAction> get request => _browseActionController.sink;
+
+  BrowserBloc({
+    @required this.webView,
+    String homePage,
+  }) : assert(webView != null) {
+    webView.setNavigationEventObserver(this);
+
+    if (homePage != null) {
+      _handleAction(NavigateToAction(url: homePage));
+    }
+    _browseActionController.stream.listen(_handleAction);
+  }
+
+  @override
+  Future<Null> onNavigationStateChanged(web.NavigationEvent event) async {
+    log.info('url loaded: ${event.url}');
+    if (!_navigationOngoing) {
+      // The event was triggered by a navigation inside the page.
+      await _addUrl(event.url);
+    }
+    _navigationOngoing = false;
+  }
+
+  Future<void> _handleAction(BrowseAction action) async {
+    switch (action.op) {
+      case BrowseActionType.navigateTo:
+        final NavigateToAction navigate = action;
+        _navigationOngoing = true;
+        await _addUrl(navigate.url, needsRedirect: true);
+        await webView.controller.loadUrl(
+            navigate.url, web.LoadUrlParams(type: web.LoadUrlReason.typed));
+        break;
+      case BrowseActionType.goBack:
+        _navigateInHistory(delta: -1);
+        _navigationOngoing = true;
+        await webView.controller.goBack();
+        break;
+      case BrowseActionType.goForward:
+        _navigateInHistory(delta: 1);
+        _navigationOngoing = true;
+        await webView.controller.goForward();
+        break;
+    }
+  }
+
+  void _navigateInHistory({@required delta}) {
+    final state = _urlListHead;
+    final newStateIndex = max(0, min(state + delta, _urlList.length - 1));
+    final oldUrl = state == -1 ? null : _urlList[state],
+        newUrl = _urlList[newStateIndex];
+    _urlListHead = newStateIndex;
+    _updateControllers();
+    _notifyNavigationUpdate(oldUrl, newUrl);
+  }
+
+  Future<Null> _addUrl(String newUrl, {bool needsRedirect = false}) async {
+    var url = newUrl;
+    if (needsRedirect) {
+      url = await _followRedirects(newUrl);
+    }
+    _urlList
+      ..removeRange(_urlListHead + 1, _urlList.length)
+      ..add(url);
+    _navigateInHistory(delta: 1);
+  }
+
+  void _updateControllers() {
+    _forwardController.add(_urlListHead < _urlList.length - 1);
+    _backController.add(_urlListHead > 0);
+  }
+
+  void _notifyNavigationUpdate(String oldUrl, String newUrl) {
+    _urlController.add(newUrl);
+  }
+
+  Future<String> _followRedirects(String url) async {
+    final request = await HttpClient().getUrl(Uri.parse(url));
+    request.followRedirects = true;
+    final response = await request.close();
+    final uri = response.redirects
+        .map((r) => r.location)
+        .toList()
+        .reversed
+        .firstWhere((url) => url.isAbsolute, orElse: () => null);
+    return uri?.toString() ?? url;
+  }
+
+  void dispose() {
+    _urlController.close();
+    _browseActionController.close();
+    _forwardController.close();
+    _backController.close();
+  }
+}
diff --git a/bin/simple_browser/lib/src/models/browse_action.dart b/bin/simple_browser/lib/src/models/browse_action.dart
new file mode 100644
index 0000000..c07770b
--- /dev/null
+++ b/bin/simple_browser/lib/src/models/browse_action.dart
@@ -0,0 +1,30 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:meta/meta.dart';
+
+// Base class for actions handled by the application's BLOC
+class BrowseAction {
+  final BrowseActionType op;
+  const BrowseAction(this.op);
+}
+
+// Operations allowed for browsing
+enum BrowseActionType { goForward, goBack, navigateTo }
+
+// Instructs to go to the next page.
+class GoForwardAction extends BrowseAction {
+  const GoForwardAction() : super(BrowseActionType.goForward);
+}
+
+// Instructs to go to the previous page.
+class GoBackAction extends BrowseAction {
+  const GoBackAction() : super(BrowseActionType.goBack);
+}
+
+// Instructs to navigate to some url.
+class NavigateToAction extends BrowseAction {
+  final String url;
+  NavigateToAction({@required this.url}) : super(BrowseActionType.navigateTo);
+}
diff --git a/bin/simple_browser/lib/src/widgets/navigation_bar.dart b/bin/simple_browser/lib/src/widgets/navigation_bar.dart
new file mode 100644
index 0000000..78daa70
--- /dev/null
+++ b/bin/simple_browser/lib/src/widgets/navigation_bar.dart
@@ -0,0 +1,99 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import '../blocs/browser_bloc.dart';
+import '../models/browse_action.dart';
+
+class NavigationBar extends StatelessWidget {
+  final BrowserBloc bloc;
+
+  const NavigationBar({this.bloc});
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+        padding: EdgeInsets.all(8.0),
+        child: Row(
+          children: [
+            Padding(
+              padding: EdgeInsets.only(right: 8.0),
+              child: StreamBuilder<bool>(
+                stream: bloc.backState,
+                initialData: false,
+                builder: (context, snapshot) => RaisedButton(
+                      padding: EdgeInsets.all(4),
+                      child: const Text('BCK'),
+                      color: Colors.grey[350],
+                      disabledColor: Colors.grey[700],
+                      onPressed: snapshot.data
+                          ? () => bloc.request.add(GoBackAction())
+                          : null,
+                    ),
+              ),
+            ),
+            Padding(
+              padding: EdgeInsets.only(right: 8.0),
+              child: StreamBuilder<bool>(
+                stream: bloc.forwardState,
+                initialData: false,
+                builder: (context, snapshot) => RaisedButton(
+                      padding: EdgeInsets.all(4),
+                      child: const Text('FWD'),
+                      color: Colors.grey[350],
+                      disabledColor: Colors.grey[700],
+                      onPressed: snapshot.data
+                          ? () => bloc.request.add(GoForwardAction())
+                          : null,
+                    ),
+              ),
+            ),
+            NavigationBox(bloc: bloc),
+          ],
+        ));
+  }
+}
+
+class NavigationBox extends StatefulWidget {
+  final BrowserBloc bloc;
+
+  const NavigationBox({this.bloc});
+
+  @override
+  NavigationBoxState createState() => NavigationBoxState(bloc);
+}
+
+class NavigationBoxState extends State<NavigationBox> {
+  final TextEditingController _controller;
+
+  NavigationBoxState(BrowserBloc bloc) : _controller = TextEditingController() {
+    bloc.url.listen((url) {
+      _controller.text = url;
+    });
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Expanded(
+      child: TextField(
+        controller: _controller,
+        decoration: InputDecoration(
+          filled: true,
+          border: InputBorder.none,
+          fillColor: Colors.white,
+          hintText: 'Enter an address...',
+        ),
+        onSubmitted: (value) =>
+            widget.bloc.request.add(NavigateToAction(url: value)),
+        textInputAction: TextInputAction.go,
+      ),
+    );
+  }
+}
diff --git a/bin/simple_browser/meta/simple_browser.cmx b/bin/simple_browser/meta/simple_browser.cmx
new file mode 100644
index 0000000..e9f51bb
--- /dev/null
+++ b/bin/simple_browser/meta/simple_browser.cmx
@@ -0,0 +1,37 @@
+{
+    "facets": {
+        "fuchsia.modular": {
+            "@version": 2,
+            "binary": "simple_browser",
+            "intent_filters": []
+        }
+    },
+    "program": {
+        "data": "data/simple_browser"
+    },
+    "sandbox": {
+        "services": [
+            "chromium.web.ContextProvider",
+            "fuchsia.cobalt.LoggerFactory",
+            "fuchsia.fonts.Provider",
+            "fuchsia.logger.LogSink",
+            "fuchsia.modular.Clipboard",
+            "fuchsia.modular.ContextWriter",
+            "fuchsia.modular.ModuleContext",
+            "fuchsia.net.SocketProvider",
+            "fuchsia.netstack.Netstack",
+            "fuchsia.process.Launcher",
+            "fuchsia.sys.Environment",
+            "fuchsia.sys.Launcher",
+            "fuchsia.ui.input.ImeService",
+            "fuchsia.ui.input.ImeVisibilityService",
+            "fuchsia.ui.scenic.Scenic",
+            "fuchsia.ui.policy.Presenter",
+            "fuchsia.ui.viewsv1.ViewManager",
+            "fuchsia.modular.ComponentContext"
+        ],
+        "system": [
+            "data/modules"
+        ]
+    }
+}
diff --git a/bin/simple_browser/pubspec.yaml b/bin/simple_browser/pubspec.yaml
new file mode 100644
index 0000000..e4eaad8
--- /dev/null
+++ b/bin/simple_browser/pubspec.yaml
@@ -0,0 +1,8 @@
+# Copyright 2019 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+name: simple_browser
+description: A simple browser for Fuchsia
+
+flutter:
diff --git a/packages/prod/BUILD.gn b/packages/prod/BUILD.gn
index 8017175..9cddb5e 100644
--- a/packages/prod/BUILD.gn
+++ b/packages/prod/BUILD.gn
@@ -270,6 +270,14 @@
   ]
 }
 
+group("simple_browser") {
+  testonly = true
+  public_deps = [
+    "//topaz/bin/simple_browser:simple_browser",
+    "//topaz/public/lib/webview:webview",
+  ]
+}
+
 group("cast_runner") {
   testonly = true
   public_deps = [