diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index a7c767aec5b8..75b2bf4997fc 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.13 + +* Add an optional `userAgent` property to set a custom User Agent. + ## 0.3.12+1 * Temporarily revert getTitle (doing this as a patch bump shortly after publishing). diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index 67fdcc71b3df..2288b8f52d5a 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -60,6 +60,10 @@ public class FlutterWebView implements PlatformView, MethodCallHandler { } updateAutoMediaPlaybackPolicy((Integer) params.get("autoMediaPlaybackPolicy")); + if (params.containsKey("userAgent")) { + String userAgent = (String) params.get("userAgent"); + updateUserAgent(userAgent); + } if (params.containsKey("initialUrl")) { String url = (String) params.get("initialUrl"); webView.loadUrl(url); @@ -241,6 +245,9 @@ private void applySettings(Map settings) { webView.setWebContentsDebuggingEnabled(debuggingEnabled); break; + case "userAgent": + updateUserAgent((String) settings.get(key)); + break; default: throw new IllegalArgumentException("Unknown WebView setting: " + key); } @@ -274,6 +281,10 @@ private void registerJavaScriptChannelNames(List channelNames) { } } + private void updateUserAgent(String userAgent) { + webView.getSettings().setUserAgentString(userAgent); + } + @Override public void dispose() { methodChannel.setMethodCallHandler(null); diff --git a/packages/webview_flutter/example/test_driver/webview.dart b/packages/webview_flutter/example/test_driver/webview.dart index fefaf6d49bef..be7e859df27c 100644 --- a/packages/webview_flutter/example/test_driver/webview.dart +++ b/packages/webview_flutter/example/test_driver/webview.dart @@ -222,6 +222,94 @@ void main() { await resizeCompleter.future; }); + test('set custom userAgent', () async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + test('use default platform userAgent after webView is rebuilt', () async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'https://flutter.dev/', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }); + group('Media playback policy', () { String audioTestBase64; setUpAll(() async { @@ -384,3 +472,12 @@ String _webviewBool(bool value) { } return value ? 'true' : 'false'; } + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await controller.evaluateJavascript('navigator.userAgent;'); + } + return jsonDecode( + await controller.evaluateJavascript('navigator.userAgent;')); +} diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/ios/Classes/FlutterWebView.m index c0aa7fd650f6..fed73d8a7d2c 100644 --- a/packages/webview_flutter/ios/Classes/FlutterWebView.m +++ b/packages/webview_flutter/ios/Classes/FlutterWebView.m @@ -250,6 +250,9 @@ - (NSString*)applySettings:(NSDictionary*)settings { _navigationDelegate.hasDartNavigationDelegate = [hasDartNavigationDelegate boolValue]; } else if ([key isEqualToString:@"debuggingEnabled"]) { // no-op debugging is always enabled on iOS. + } else if ([key isEqualToString:@"userAgent"]) { + NSString* userAgent = settings[key]; + [self updateUserAgent:[userAgent isEqual:[NSNull null]] ? nil : userAgent]; } else { [unknownKeys addObject:key]; } @@ -347,4 +350,12 @@ - (void)registerJavaScriptChannels:(NSSet*)channelNames } } +- (void)updateUserAgent:(NSString*)userAgent { + if (@available(iOS 9.0, *)) { + [_webView setCustomUserAgent:userAgent]; + } else { + NSLog(@"Updating UserAgent is not supported for Flutter WebViews prior to iOS 9."); + } +} + @end diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/lib/platform_interface.dart index 4e6b8b86cacf..061b3aa55e66 100644 --- a/packages/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/lib/platform_interface.dart @@ -154,16 +154,66 @@ abstract class WebViewPlatformController { } } +/// A single setting for configuring a WebViewPlatform which may be absent. +class WebSetting { + /// Constructs an absent setting instance. + /// + /// The [isPresent] field for the instance will be false. + /// + /// Accessing [value] for an absent instance will throw. + WebSetting.absent() + : _value = null, + isPresent = false; + + /// Constructs a setting of the given `value`. + /// + /// The [isPresent] field for the instance will be true. + WebSetting.of(T value) + : _value = value, + isPresent = true; + + final T _value; + + /// The setting's value. + /// + /// Throws if [WebSetting.isPresent] is false. + T get value { + if (!isPresent) { + throw StateError('Cannot access a value of an absent WebSetting'); + } + assert(isPresent); + return _value; + } + + /// True when this web setting instance contains a value. + /// + /// When false the [WebSetting.value] getter throws. + final bool isPresent; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + final WebSetting typedOther = other; + return typedOther.isPresent == isPresent && typedOther._value == _value; + } + + @override + int get hashCode => hashValues(_value, isPresent); +} + /// Settings for configuring a WebViewPlatform. /// /// Initial settings are passed as part of [CreationParams], settings updates are sent with /// [WebViewPlatform#updateSettings]. +/// +/// The `userAgent` parameter must not be null. class WebSettings { WebSettings({ this.javascriptMode, this.hasNavigationDelegate, this.debuggingEnabled, - }); + @required this.userAgent, + }) : assert(userAgent != null); /// The JavaScript execution mode to be used by the webview. final JavascriptMode javascriptMode; @@ -176,9 +226,19 @@ class WebSettings { /// See also: [WebView.debuggingEnabled]. final bool debuggingEnabled; + /// The value used for the HTTP `User-Agent:` request header. + /// + /// If [userAgent.value] is null the platform's default user agent should be used. + /// + /// An absent value ([userAgent.isPresent] is false) represents no change to this setting from the + /// last time it was set. + /// + /// See also [WebView.userAgent]. + final WebSetting userAgent; + @override String toString() { - return 'WebSettings(javascriptMode: $javascriptMode, hasNavigationDelegate: $hasNavigationDelegate, debuggingEnabled: $debuggingEnabled)'; + return 'WebSettings(javascriptMode: $javascriptMode, hasNavigationDelegate: $hasNavigationDelegate, debuggingEnabled: $debuggingEnabled, userAgent: $userAgent,)'; } } @@ -190,6 +250,7 @@ class CreationParams { this.initialUrl, this.webSettings, this.javascriptChannelNames, + this.userAgent, this.autoMediaPlaybackPolicy = AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, }) : assert(autoMediaPlaybackPolicy != null); @@ -217,12 +278,17 @@ class CreationParams { // to PlatformWebView. final Set javascriptChannelNames; + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + final String userAgent; + /// Which restrictions apply on automatic media playback. final AutoMediaPlaybackPolicy autoMediaPlaybackPolicy; @override String toString() { - return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames)'; + return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames, UserAgent: $userAgent)'; } } diff --git a/packages/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/lib/src/webview_method_channel.dart index a914e1828b6b..f34000569551 100644 --- a/packages/webview_flutter/lib/src/webview_method_channel.dart +++ b/packages/webview_flutter/lib/src/webview_method_channel.dart @@ -119,9 +119,17 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { map[key] = value; } + void _addSettingIfPresent(String key, WebSetting setting) { + if (!setting.isPresent) { + return; + } + map[key] = setting.value; + } + _addIfNonNull('jsMode', settings.javascriptMode?.index); _addIfNonNull('hasNavigationDelegate', settings.hasNavigationDelegate); _addIfNonNull('debuggingEnabled', settings.debuggingEnabled); + _addSettingIfPresent('userAgent', settings.userAgent); return map; } @@ -135,6 +143,7 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { 'initialUrl': creationParams.initialUrl, 'settings': _webSettingsToMap(creationParams.webSettings), 'javascriptChannelNames': creationParams.javascriptChannelNames.toList(), + 'userAgent': creationParams.userAgent, 'autoMediaPlaybackPolicy': creationParams.autoMediaPlaybackPolicy.index, }; } diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index 4335ed27ada8..97b7786de9a6 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -140,6 +140,7 @@ class WebView extends StatefulWidget { this.gestureRecognizers, this.onPageFinished, this.debuggingEnabled = false, + this.userAgent, this.initialMediaPlaybackPolicy = AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, }) : assert(javascriptMode != null), @@ -277,6 +278,20 @@ class WebView extends StatefulWidget { /// By default `debuggingEnabled` is false. final bool debuggingEnabled; + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String userAgent; + /// Which restrictions apply on automatic media playback. /// /// This initial value is applied to the platform's webview upon creation. Any following @@ -347,6 +362,7 @@ CreationParams _creationParamsfromWidget(WebView widget) { initialUrl: widget.initialUrl, webSettings: _webSettingsFromWidget(widget), javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), + userAgent: widget.userAgent, autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, ); } @@ -356,6 +372,7 @@ WebSettings _webSettingsFromWidget(WebView widget) { javascriptMode: widget.javascriptMode, hasNavigationDelegate: widget.navigationDelegate != null, debuggingEnabled: widget.debuggingEnabled, + userAgent: WebSetting.of(widget.userAgent), ); } @@ -365,12 +382,16 @@ WebSettings _clearUnchangedWebSettings( assert(currentValue.javascriptMode != null); assert(currentValue.hasNavigationDelegate != null); assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent.isPresent); assert(newValue.javascriptMode != null); assert(newValue.hasNavigationDelegate != null); assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent.isPresent); + JavascriptMode javascriptMode; bool hasNavigationDelegate; bool debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); if (currentValue.javascriptMode != newValue.javascriptMode) { javascriptMode = newValue.javascriptMode; } @@ -380,11 +401,15 @@ WebSettings _clearUnchangedWebSettings( if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { debuggingEnabled = newValue.debuggingEnabled; } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } return WebSettings( javascriptMode: javascriptMode, hasNavigationDelegate: hasNavigationDelegate, debuggingEnabled: debuggingEnabled, + userAgent: userAgent, ); } diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index 206b481ce0bf..23c09e81444f 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: webview_flutter description: A Flutter plugin that provides a WebView widget on Android and iOS. -version: 0.3.12+1 +version: 0.3.13 author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/webview_flutter diff --git a/packages/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/test/webview_flutter_test.dart index d451a86b19c8..6907436b24a2 100644 --- a/packages/webview_flutter/test/webview_flutter_test.dart +++ b/packages/webview_flutter/test/webview_flutter_test.dart @@ -776,6 +776,7 @@ void main() { javascriptMode: JavascriptMode.disabled, hasNavigationDelegate: false, debuggingEnabled: false, + userAgent: WebSetting.of(null), ), // TODO(iskakaushik): Remove this when collection literals makes it to stable. // ignore: prefer_collection_literals @@ -807,6 +808,25 @@ void main() { expect(platform.lastRequestHeaders, headers); }); }); + testWidgets('Set UserAgent', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.unrestricted, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView; + + expect(platformWebView.userAgent, isNull); + + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'UA', + )); + + expect(platformWebView.userAgent, 'UA'); + }); } class FakePlatformWebView { @@ -826,7 +846,7 @@ class FakePlatformWebView { hasNavigationDelegate = params['settings']['hasNavigationDelegate'] ?? false; debuggingEnabled = params['settings']['debuggingEnabled']; - + userAgent = params['settings']['userAgent']; channel = MethodChannel( 'plugins.flutter.io/webview_$id', const StandardMethodCodec()); channel.setMockMethodCallHandler(onMethodCall); @@ -845,6 +865,7 @@ class FakePlatformWebView { bool hasNavigationDelegate; bool debuggingEnabled; + String userAgent; Future onMethodCall(MethodCall call) { switch (call.method) { @@ -862,6 +883,7 @@ class FakePlatformWebView { if (call.arguments['debuggingEnabled'] != null) { debuggingEnabled = call.arguments['debuggingEnabled']; } + userAgent = call.arguments['userAgent']; break; case 'canGoBack': return Future.sync(() => currentPosition > 0); @@ -1092,7 +1114,8 @@ class MatchesWebSettings extends Matcher { return _webSettings.javascriptMode == webSettings.javascriptMode && _webSettings.hasNavigationDelegate == webSettings.hasNavigationDelegate && - _webSettings.debuggingEnabled == webSettings.debuggingEnabled; + _webSettings.debuggingEnabled == webSettings.debuggingEnabled && + _webSettings.userAgent == webSettings.userAgent; } }