diff --git a/CODEOWNERS b/CODEOWNERS index 5a5749661927..8b9a96d8fd8c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -18,6 +18,7 @@ packages/flutter_migrate/** @stuartmorgan packages/flutter_template_images/** @stuartmorgan packages/go_router/** @chunhtai packages/go_router_builder/** @chunhtai +packages/google_adsense/** @sokoloff06 packages/google_identity_services_web/** @ditman packages/google_maps_flutter/** @stuartmorgan packages/google_sign_in/** @stuartmorgan diff --git a/README.md b/README.md index 2d728c32ac11..64dce4761744 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ These are the packages hosted in this repository: | [flutter\_template\_images](./packages/flutter_template_images/) | [![pub package](https://img.shields.io/pub/v/flutter_template_images.svg)](https://pub.dev/packages/flutter_template_images) | [![pub points](https://img.shields.io/pub/points/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![popularity](https://img.shields.io/pub/popularity/flutter_template_images)](https://pub.dev/packages/flutter_template_images/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20flutter_template_images?label=)](https://github.com/flutter/flutter/labels/p%3A%20flutter_template_images) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20flutter_template_images?label=)](https://github.com/flutter/packages/labels/p%3A%20flutter_template_images) | | [go\_router](./packages/go_router/) | [![pub package](https://img.shields.io/pub/v/go_router.svg)](https://pub.dev/packages/go_router) | [![pub points](https://img.shields.io/pub/points/go_router)](https://pub.dev/packages/go_router/score) | [![popularity](https://img.shields.io/pub/popularity/go_router)](https://pub.dev/packages/go_router/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20go_router?label=)](https://github.com/flutter/flutter/labels/p%3A%20go_router) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20go_router?label=)](https://github.com/flutter/packages/labels/p%3A%20go_router) | | [go\_router\_builder](./packages/go_router_builder/) | [![pub package](https://img.shields.io/pub/v/go_router_builder.svg)](https://pub.dev/packages/go_router_builder) | [![pub points](https://img.shields.io/pub/points/go_router_builder)](https://pub.dev/packages/go_router_builder/score) | [![popularity](https://img.shields.io/pub/popularity/go_router_builder)](https://pub.dev/packages/go_router_builder/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20go_router_builder?label=)](https://github.com/flutter/flutter/labels/p%3A%20go_router_builder) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20go_router_builder?label=)](https://github.com/flutter/packages/labels/p%3A%20go_router_builder) | +| [google\_adsense](./packages/google_adsense/)| [![pub package](https://img.shields.io/pub/v/google_adsense.svg)](https://pub.dev/packages/google_adsense) | [![pub points](https://img.shields.io/pub/points/google_adsense)](https://pub.dev/packages/google_adsense/score) | [![popularity](https://img.shields.io/pub/popularity/google_adsense)](https://pub.dev/packages/google_adsense/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20google_adsense?label=)](https://github.com/flutter/flutter/labels/p%3A%20google_adsense) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20google_adsense?label=)](https://github.com/flutter/packages/labels/p%3A%20google_adsense) | | [google\_maps\_flutter](./packages/google_maps_flutter/) | [![pub package](https://img.shields.io/pub/v/google_maps_flutter.svg)](https://pub.dev/packages/google_maps_flutter) | [![pub points](https://img.shields.io/pub/points/google_maps_flutter)](https://pub.dev/packages/google_maps_flutter/score) | [![popularity](https://img.shields.io/pub/popularity/google_maps_flutter)](https://pub.dev/packages/google_maps_flutter/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20maps?label=)](https://github.com/flutter/flutter/labels/p%3A%20maps) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20google_maps_flutter?label=)](https://github.com/flutter/packages/labels/p%3A%20google_maps_flutter) | | [google\_sign\_in](./packages/google_sign_in/) | [![pub package](https://img.shields.io/pub/v/google_sign_in.svg)](https://pub.dev/packages/google_sign_in) | [![pub points](https://img.shields.io/pub/points/google_sign_in)](https://pub.dev/packages/google_sign_in/score) | [![popularity](https://img.shields.io/pub/popularity/google_sign_in)](https://pub.dev/packages/google_sign_in/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20google_sign_in?label=)](https://github.com/flutter/flutter/labels/p%3A%20google_sign_in) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20google_sign_in?label=)](https://github.com/flutter/packages/labels/p%3A%20google_sign_in) | | [image\_picker](./packages/image_picker/) | [![pub package](https://img.shields.io/pub/v/image_picker.svg)](https://pub.dev/packages/image_picker) | [![pub points](https://img.shields.io/pub/points/image_picker)](https://pub.dev/packages/image_picker/score) | [![popularity](https://img.shields.io/pub/popularity/image_picker)](https://pub.dev/packages/image_picker/score) | [![GitHub issues by-label](https://img.shields.io/github/issues/flutter/flutter/p%3A%20image_picker?label=)](https://github.com/flutter/flutter/labels/p%3A%20image_picker) | [![GitHub pull requests by-label](https://img.shields.io/github/issues-pr/flutter/packages/p%3A%20image_picker?label=)](https://github.com/flutter/packages/labels/p%3A%20image_picker) | diff --git a/packages/google_adsense/AUTHORS b/packages/google_adsense/AUTHORS new file mode 100644 index 000000000000..eb34297f179e --- /dev/null +++ b/packages/google_adsense/AUTHORS @@ -0,0 +1,7 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors diff --git a/packages/google_adsense/CHANGELOG.md b/packages/google_adsense/CHANGELOG.md new file mode 100644 index 000000000000..d0bd041d0ff6 --- /dev/null +++ b/packages/google_adsense/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* Initial release. diff --git a/packages/google_adsense/LICENSE b/packages/google_adsense/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/google_adsense/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/google_adsense/README.md b/packages/google_adsense/README.md new file mode 100644 index 000000000000..e7fb1b806938 --- /dev/null +++ b/packages/google_adsense/README.md @@ -0,0 +1,120 @@ +# google_adsense +[Google AdSense](https://adsense.google.com/intl/en_us/start/) plugin for Flutter Web + +This package initializes AdSense on your website and provides an ad unit `Widget` that can be configured and placed in the desired location in your Flutter web app UI, without having to directly modify the HTML markup of the app directly. + +## Disclaimer: Early Access ⚠️ +This package is currently in early access and is provided as-is. While it's open source and publicly available, it's likely that you'll need to make additional customizations and configurations to fully integrate it with your Flutter Web App. +Please express interest joining Early Access program using [this form](https://docs.google.com/forms/d/e/1FAIpQLSdN6aOwVkaxGdxbVQFVZ_N4_UCBkuWYa-cS4_rbU_f1jK10Tw/viewform) + +## Usage + +### Setup your AdSense account +1. [Make sure your site's pages are ready for AdSense](https://support.google.com/adsense/answer/7299563?hl=en&sjid=5790642343077592212-EU&visit_id=638657100661171978-1373860041&ref_topic=1319756&rd=1) +2. [Create your AdSense account](https://support.google.com/adsense/answer/10162?hl=en&sjid=5790642343077592212-EU&visit_id=638657100661171978-1373860041&ref_topic=1250103&rd=1) + +### Initialize AdSense +To start displaying ads, initialize the AdSense with your [client/publisher ID](https://support.google.com/adsense/answer/105516?hl=en&sjid=5790642343077592212-EU) (only use numbers). + +```dart +import 'package:google_adsense/experimental/google_adsense.dart'; + +void main() { + adSense.initialize( + '0123456789012345'); // TODO: Replace with your Publisher ID (pub-0123456789012345) - https://support.google.com/adsense/answer/105516?hl=en&sjid=5790642343077592212-EU + runApp(const MyApp()); +} + +``` + +### Enable Auto Ads +In order to start displaying [Auto ads](https://support.google.com/adsense/answer/9261805?hl=en) make sure to configure this feature in your AdSense Console. If you want to display ad units within your app content, continue to the next step + +### Display ad unit Widget + +1. Create [ad units](https://support.google.com/adsense/answer/9183549?hl=en&ref_topic=9183242&sjid=5790642343077592212-EU) in your AdSense account +2. Use relevant `AdUnitConfiguration` constructor as per table below + +| Ad Unit Type | `AdUnitConfiguration` constructor method | +|----------------|--------------------------------------------| +| Display Ads | `AdUnitConfiguration.displayAdUnit(...)` | +| In-feed Ads | `AdUnitConfiguration.inFeedAdUnit(...)` | +| In-article Ads | `AdUnitConfiguration.inArticleAdUnit(...)` | +| Multiplex Ads | `AdUnitConfiguration.multiplexAdUnit(...)` | + +3. Translate data-attributes from snippet generated in AdSense Console into constructor arguments as described below: +- drop `data-` prefix +- translate kebab-case to camelCase +- no need to translate `data-ad-client` as it the value was already passed at initialization + +For example snippet below +```html + + +``` +translates into + +```dart +adSense.initialize( + '0123456789012345'); // TODO: Replace with your Publisher ID (pub-0123456789012345) - https://support.google.com/adsense/answer/105516?hl=en&sjid=5790642343077592212-EU +``` +and + +```dart + adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: '1234567890', // TODO: Replace with your Ad Unit ID + adFormat: AdFormat + .AUTO, // Remove AdFormat to make ads limited by height +)) +``` + +#### Customize ad unit Widget +To [modify your responsive ad code](https://support.google.com/adsense/answer/9183363?hl=en&ref_topic=9183242&sjid=11551379421978541034-EU): +1. Make sure to follow [AdSense policies](https://support.google.com/adsense/answer/1346295?hl=en&sjid=18331098933308334645-EU&visit_id=638689380593964621-4184295127&ref_topic=1271508&rd=1) +2. Use Flutter instruments for [adaptive and responsive design](https://docs.flutter.dev/ui/adaptive-responsive) + +For example, when not using responsive `AdFormat` it is recommended to wrap adUnit widget in the `Container` with width and/or height constraints. +Note some [policies and restrictions](https://support.google.com/adsense/answer/9185043?hl=en#:~:text=Policies%20and%20restrictions) related to ad unit sizing: + + +```dart +Container( + constraints: + const BoxConstraints(maxHeight: 100, maxWidth: 1200), + padding: const EdgeInsets.only(bottom: 10), + child: adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: '1234567890', // TODO: Replace with your Ad Unit ID + adFormat: AdFormat + .AUTO, // Not using AdFormat to make ad unit respect height constraint + )), +), +``` +## Testing and common errors + +### Failed to load resource: the server responded with a status of 400 +Make sure to set correct values to adSlot and adClient arguments + +### Failed to load resource: the server responded with a status of 403 +1. When happening in **testing/staging** environment it is likely related to the fact that ads are only filled when requested from an authorized domain. If you are testing locally and running your web app on `localhost`, you need to: + 1. Set custom domain name on localhost by creating a local DNS record that would point `127.0.0.1` and/or `localhost` to `your-domain.com`. On mac/linux machines this can be achieved by adding the following records to you /etc/hosts file: + `127.0.0.1 your-domain.com` + `localhost your-domain.com` + 2. Specify additional run arguments in IDE by editing `Run/Debug Configuration` or by passing them directly to `flutter run` command: + `--web-port=8080` + `--web-hostname=your-domain.com` +2. When happening in **production** it might be that your domain was not yet approved or was disapproved. Login to your AdSense account to check your domain approval status + +### Ad unfilled + +There is no deterministic way to make sure your ads are 100% filled even when testing. Some of the way to increase the fill rate: +- Try setting `adTest` parameter to `true` +- Try setting AD_FORMAT to `auto` (default setting) +- Try setting FULL_WIDTH_RESPONSIVE to `true` (default setting) +- Try resizing the window or making sure that ad unit Widget width is less than ~1200px diff --git a/packages/google_adsense/dart_test.yaml b/packages/google_adsense/dart_test.yaml new file mode 100644 index 000000000000..d2be6eb42268 --- /dev/null +++ b/packages/google_adsense/dart_test.yaml @@ -0,0 +1,6 @@ +## See https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#arguments +#override_platforms: +# chrome: +# settings: +# executable: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome +# arguments: --no-sandbox \ No newline at end of file diff --git a/packages/google_adsense/example/README.md b/packages/google_adsense/example/README.md new file mode 100644 index 000000000000..afc9bad27e5c --- /dev/null +++ b/packages/google_adsense/example/README.md @@ -0,0 +1,21 @@ +# google_adsense_example + +An example demonstrating google_adsense Flutter plugin usage + +## Screenshots + +![Screenshot of the test app showing an ad on mobile](../example/images/mobile_screenshot.png) +![Screenshot of the test app showing an ad on desktop](../example/images/desktop_screenshot.jpg) + + + +## Testing + +This package uses `package:integration_test` to run its tests in a web browser. + +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/blob/master/docs/ecosystem/testing/Plugin-Tests.md#web-tests) +in the Flutter documentation for instructions to set up and run the tests in this package. + +Check [flutter.dev > Integration testing](https://docs.flutter.dev/testing/integration-tests) +for more info. + diff --git a/packages/google_adsense/example/images/desktop_screenshot.jpg b/packages/google_adsense/example/images/desktop_screenshot.jpg new file mode 100644 index 000000000000..8dba62f8c7aa Binary files /dev/null and b/packages/google_adsense/example/images/desktop_screenshot.jpg differ diff --git a/packages/google_adsense/example/images/mobile_screenshot.png b/packages/google_adsense/example/images/mobile_screenshot.png new file mode 100644 index 000000000000..7525bcf30403 Binary files /dev/null and b/packages/google_adsense/example/images/mobile_screenshot.png differ diff --git a/packages/google_adsense/example/integration_test/ad_widget_test.dart b/packages/google_adsense/example/integration_test/ad_widget_test.dart new file mode 100644 index 000000000000..43b7dfb63041 --- /dev/null +++ b/packages/google_adsense/example/integration_test/ad_widget_test.dart @@ -0,0 +1,243 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TO run the test: +// 1. Run chrome driver with --port=4444 +// 2. Run the test from example folder with: flutter drive -d web-server --web-port 7357 --browser-name chrome --driver test_driver/integration_test.dart --target integration_test/ad_widget_test.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +// Ensure we don't use the singleton `adSense`, but the local copies to this plugin. +import 'package:google_adsense/experimental/google_adsense.dart' hide adSense; +import 'package:google_adsense/src/ad_unit_widget.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web/web.dart' as web; + +import 'test_js_interop.dart'; + +const String testClient = 'test_client'; +const String testSlot = 'test_slot'; +const String testScriptUrl = + 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-$testClient'; + +void main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late AdSense adSense; + + setUp(() async { + adSense = AdSense(); + }); + + tearDown(() { + clearAdsByGoogleMock(); + }); + + group('initialization', () { + testWidgets('Initialization adds AdSense snippet.', (WidgetTester _) async { + final web.HTMLElement target = web.HTMLDivElement(); + // Given + + adSense.initialize(testClient, jsLoaderTarget: target); + + final web.HTMLScriptElement? injected = + target.lastElementChild as web.HTMLScriptElement?; + + expect(injected, isNotNull); + expect(injected!.src, testScriptUrl); + expect(injected.crossOrigin, 'anonymous'); + expect(injected.async, true); + }); + + testWidgets('Skips initialization if script is already present.', + (WidgetTester _) async { + final web.HTMLScriptElement script = web.HTMLScriptElement() + ..id = 'previously-injected' + ..src = testScriptUrl; + final web.HTMLElement target = web.HTMLDivElement()..appendChild(script); + + adSense.initialize(testClient, jsLoaderTarget: target); + + expect(target.childElementCount, 1); + expect(target.firstElementChild?.id, 'previously-injected'); + }); + + testWidgets('Skips initialization if adsense object is already present.', + (WidgetTester _) async { + final web.HTMLElement target = web.HTMLDivElement(); + + // Write an empty noop object + mockAdsByGoogle(() {}); + + adSense.initialize(testClient, jsLoaderTarget: target); + + expect(target.firstElementChild, isNull); + }); + }); + + group('adWidget', () { + testWidgets('Responsive (with adFormat) ad units reflow flutter', + (WidgetTester tester) async { + // The size of the ad that we're going to "inject" + const double expectedHeight = 137; + + // When + mockAdsByGoogle( + mockAd( + size: const Size(320, expectedHeight), + ), + ); + + adSense.initialize(testClient); + + final Widget adUnitWidget = adSense.adUnit( + AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + adFormat: AdFormat.AUTO, // Important! + ), + ); + + await pumpAdWidget(adUnitWidget, tester); + + // Then + // Widget level + final Finder adUnit = find.byWidget(adUnitWidget); + expect(adUnit, findsOneWidget); + + final Size size = tester.getSize(adUnit); + expect(size.height, expectedHeight); + }); + + testWidgets( + 'Fixed size (without adFormat) ad units respect flutter constraints', + (WidgetTester tester) async { + const double maxHeight = 100; + const BoxConstraints constraints = BoxConstraints(maxHeight: maxHeight); + + // When + mockAdsByGoogle( + mockAd( + size: const Size(320, 157), + ), + ); + + adSense.initialize(testClient); + + final Widget adUnitWidget = adSense.adUnit( + AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + ), + ); + + final Widget constrainedAd = Container( + constraints: constraints, + child: adUnitWidget, + ); + + await pumpAdWidget(constrainedAd, tester); + + // Then + // Widget level + final Finder adUnit = find.byWidget(adUnitWidget); + expect(adUnit, findsOneWidget); + + final Size size = tester.getSize(adUnit); + expect(size.height, maxHeight); + }); + + testWidgets('Unfilled ad units collapse widget height', + (WidgetTester tester) async { + // When + mockAdsByGoogle(mockAd(adStatus: AdStatus.UNFILLED)); + + adSense.initialize(testClient); + final Widget adUnitWidget = adSense.adUnit( + AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + ), + ); + + await pumpAdWidget(adUnitWidget, tester); + + // Then + expect(find.byType(HtmlElementView), findsNothing, + reason: 'Unfilled ads should remove their platform view'); + + final Finder adUnit = find.byWidget(adUnitWidget); + expect(adUnit, findsOneWidget); + + final Size size = tester.getSize(adUnit); + expect(size.height, 0); + }); + + testWidgets('Can handle multiple ads', (WidgetTester tester) async { + // When + mockAdsByGoogle( + mockAds([ + (size: const Size(320, 200), adStatus: AdStatus.FILLED), + (size: Size.zero, adStatus: AdStatus.UNFILLED), + (size: const Size(640, 90), adStatus: AdStatus.FILLED), + ]), + ); + + adSense.initialize(testClient); + + final Widget bunchOfAds = Column( + children: [ + adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + adFormat: AdFormat.AUTO, + )), + adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + adFormat: AdFormat.AUTO, + )), + Container( + constraints: const BoxConstraints(maxHeight: 100), + child: adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: testSlot, + )), + ), + ], + ); + + await pumpAdWidget(bunchOfAds, tester); + + // Then + // Widget level + final Finder platformViews = find.byType(HtmlElementView); + expect(platformViews, findsExactly(2), + reason: 'The platform view of unfilled ads should be removed.'); + + final Finder adUnits = find.byType(AdUnitWidget); + expect(adUnits, findsExactly(3)); + + expect(tester.getSize(adUnits.at(0)).height, 200, + reason: 'Responsive ad widget should resize to match its `ins`'); + expect(tester.getSize(adUnits.at(1)).height, 0, + reason: 'Unfulfilled ad should be 0x0'); + expect(tester.getSize(adUnits.at(2)).height, 100, + reason: 'The constrained ad should use the height of container'); + }); + }); +} + +// Pumps an AdUnit Widget into a given tester, with some parameters +Future pumpAdWidget(Widget adUnit, WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: adUnit, + ), + ), + ), + ); + + // This extra pump is needed for the platform view to actually render in the DOM. + await tester.pump(); + + // This extra pump is needed to simulate the async behavior of the adsense JS mock. + await tester.pumpAndSettle(); +} diff --git a/packages/google_adsense/example/integration_test/script_tag_test.dart b/packages/google_adsense/example/integration_test/script_tag_test.dart new file mode 100644 index 000000000000..c4671eaaf531 --- /dev/null +++ b/packages/google_adsense/example/integration_test/script_tag_test.dart @@ -0,0 +1,37 @@ +// Copyright 2013 The Flutter 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_test/flutter_test.dart'; +import 'package:google_adsense/experimental/google_adsense.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web/web.dart' as web; + +const String testClient = 'test_client'; + +void main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // We test this separately so we don't have to worry about removing the script + // from the page. Tests in `ad_widget_test.dart` use overrides in `initialize` + // to keep them self-contained. + group('JS initialization', () { + testWidgets('Initialization adds AdSense snippet.', (WidgetTester _) async { + // Given + const String expectedScriptUrl = + 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-$testClient'; + + // When (using the singleton adSense from the plugin) + adSense.initialize(testClient); + + // Then + final web.HTMLScriptElement? injected = + web.document.head?.lastElementChild as web.HTMLScriptElement?; + + expect(injected, isNotNull); + expect(injected!.src, expectedScriptUrl); + expect(injected.crossOrigin, 'anonymous'); + expect(injected.async, true); + }); + }); +} diff --git a/packages/google_adsense/example/integration_test/test_js_interop.dart b/packages/google_adsense/example/integration_test/test_js_interop.dart new file mode 100644 index 000000000000..d943ac980df9 --- /dev/null +++ b/packages/google_adsense/example/integration_test/test_js_interop.dart @@ -0,0 +1,90 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@JS() +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:ui'; + +import 'package:google_adsense/experimental/google_adsense.dart'; +import 'package:web/web.dart' as web; + +typedef VoidFn = void Function(); + +// window.adsbygoogle uses "duck typing", so let us set anything to it. +@JS('adsbygoogle') +external set _adsbygoogle(JSAny? value); + +/// Mocks `adsbygoogle` [push] function. +/// +/// `push` will run in the next tick (`Timer.run`) to ensure async behavior. +void mockAdsByGoogle(VoidFn push) { + _adsbygoogle = { + 'push': () { + Timer.run(push); + }.toJS, + }.jsify(); +} + +/// Sets `adsbygoogle` to null. +void clearAdsByGoogleMock() { + _adsbygoogle = null; +} + +typedef MockAdConfig = ({Size size, String adStatus}); + +/// Returns a function that generates a "push" function for [mockAdsByGoogle]. +VoidFn mockAd({ + Size size = Size.zero, + String adStatus = AdStatus.FILLED, +}) { + return mockAds( + [(size: size, adStatus: adStatus)], + ); +} + +/// Returns a function that handles a bunch of ad units at once. Can be used with [mockAdsByGoogle]. +VoidFn mockAds(List adConfigs) { + return () { + final List foundTargets = + web.document.querySelectorAll('div[id^=adUnit] ins').toList; + + for (int i = 0; i < foundTargets.length; i++) { + final web.HTMLElement adTarget = foundTargets[i]; + if (adTarget.children.length > 0) { + continue; + } + + final (:Size size, :String adStatus) = adConfigs[i]; + + final web.HTMLElement fakeAd = web.HTMLDivElement() + ..style.width = '${size.width}px' + ..style.height = '${size.height}px' + ..style.background = '#fabada'; + + // AdSense seems to be setting the width/height on the `ins` of the injected ad too. + adTarget + ..style.width = '${size.width}px' + ..style.height = '${size.height}px' + ..style.display = 'block' + ..appendChild(fakeAd) + ..setAttribute('data-ad-status', adStatus); + } + }; +} + +extension on web.NodeList { + List get toList { + final List result = []; + for (int i = 0; i < length; i++) { + final web.Node? node = item(i); + if (node != null && node.isA()) { + result.add(node as web.HTMLElement); + } + } + return result; + } +} diff --git a/packages/google_adsense/example/lib/main.dart b/packages/google_adsense/example/lib/main.dart new file mode 100644 index 000000000000..91a4c3b2373d --- /dev/null +++ b/packages/google_adsense/example/lib/main.dart @@ -0,0 +1,110 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: flutter_style_todos + +import 'package:flutter/material.dart'; + +// #docregion init +import 'package:google_adsense/experimental/google_adsense.dart'; + +void main() { + // #docregion init-min + adSense.initialize( + '0123456789012345'); // TODO: Replace with your Publisher ID (pub-0123456789012345) - https://support.google.com/adsense/answer/105516?hl=en&sjid=5790642343077592212-EU + // #enddocregion init-min + runApp(const MyApp()); +} + +// #enddocregion init +/// The main app. +class MyApp extends StatelessWidget { + /// Constructs a [MyApp] + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(), + ); + } +} + +/// The home screen +class MyHomePage extends StatefulWidget { + /// Constructs a [HomeScreen] + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('AdSense for Flutter demo app'), + ), + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Responsive Ad Constrained by width of 150px:', + ), + Container( + constraints: const BoxConstraints(maxWidth: 150), + padding: const EdgeInsets.only(bottom: 10), + child: + // #docregion adUnit + adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: '1234567890', // TODO: Replace with your Ad Unit ID + adFormat: AdFormat + .AUTO, // Remove AdFormat to make ads limited by height + )) + // #enddocregion adUnit + , + ), + const Text( + 'Responsive Ad Constrained by height of 100px and width of 1200px (to keep ad centered):', + ), + // #docregion constraints + Container( + constraints: + const BoxConstraints(maxHeight: 100, maxWidth: 1200), + padding: const EdgeInsets.only(bottom: 10), + child: adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: '1234567890', // TODO: Replace with your Ad Unit ID + adFormat: AdFormat + .AUTO, // Not using AdFormat to make ad unit respect height constraint + )), + ), + // #enddocregion constraints + const Text( + 'Fixed 125x125 size Ad:', + ), + Container( + height: 125, + width: 125, + padding: const EdgeInsets.only(bottom: 10), + child: adSense.adUnit(AdUnitConfiguration.displayAdUnit( + adSlot: '1234567890', // TODO: Replace with your Ad Unit ID + // adFormat: AdFormat.AUTO, // Not using AdFormat to make ad unit respect height constraint + isFullWidthResponsive: false)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/google_adsense/example/pubspec.yaml b/packages/google_adsense/example/pubspec.yaml new file mode 100644 index 000000000000..248e201750cf --- /dev/null +++ b/packages/google_adsense/example/pubspec.yaml @@ -0,0 +1,26 @@ +name: google_adsense_example +description: "A new Flutter project." +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ^3.4.0 + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + google_adsense: + path: ../ + web: ^1.0.0 + +dev_dependencies: + flutter_lints: ^3.0.0 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/google_adsense/example/test_driver/integration_test.dart b/packages/google_adsense/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/google_adsense/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter 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:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/google_adsense/example/web/favicon.png b/packages/google_adsense/example/web/favicon.png new file mode 100644 index 000000000000..8aaa46ac1ae2 Binary files /dev/null and b/packages/google_adsense/example/web/favicon.png differ diff --git a/packages/google_adsense/example/web/icons/Icon-192.png b/packages/google_adsense/example/web/icons/Icon-192.png new file mode 100644 index 000000000000..b749bfef0747 Binary files /dev/null and b/packages/google_adsense/example/web/icons/Icon-192.png differ diff --git a/packages/google_adsense/example/web/icons/Icon-512.png b/packages/google_adsense/example/web/icons/Icon-512.png new file mode 100644 index 000000000000..88cfd48dff11 Binary files /dev/null and b/packages/google_adsense/example/web/icons/Icon-512.png differ diff --git a/packages/google_adsense/example/web/icons/Icon-maskable-192.png b/packages/google_adsense/example/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000000..eb9b4d76e525 Binary files /dev/null and b/packages/google_adsense/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/google_adsense/example/web/icons/Icon-maskable-512.png b/packages/google_adsense/example/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000000..d69c56691fbd Binary files /dev/null and b/packages/google_adsense/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/google_adsense/example/web/index.html b/packages/google_adsense/example/web/index.html new file mode 100644 index 000000000000..e1611098ded7 --- /dev/null +++ b/packages/google_adsense/example/web/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + google_adsense_example + + + + + + diff --git a/packages/google_adsense/example/web/manifest.json b/packages/google_adsense/example/web/manifest.json new file mode 100644 index 000000000000..79d612a06590 --- /dev/null +++ b/packages/google_adsense/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "google_adsense_example", + "short_name": "google_adsense_example", + "start_url": ".", + "display": "standalone", + "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" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/google_adsense/lib/experimental/google_adsense.dart b/packages/google_adsense/lib/experimental/google_adsense.dart new file mode 100644 index 000000000000..cbeabcc1418a --- /dev/null +++ b/packages/google_adsense/lib/experimental/google_adsense.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +export '../src/ad_unit_configuration.dart'; +export '../src/ad_unit_params.dart'; +export '../src/adsense_stub.dart' + if (dart.library.js_interop) '../src/adsense_web.dart'; diff --git a/packages/google_adsense/lib/google_adsense.dart b/packages/google_adsense/lib/google_adsense.dart new file mode 100644 index 000000000000..e7217c7d7b3f --- /dev/null +++ b/packages/google_adsense/lib/google_adsense.dart @@ -0,0 +1,3 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. diff --git a/packages/google_adsense/lib/src/ad_unit_configuration.dart b/packages/google_adsense/lib/src/ad_unit_configuration.dart new file mode 100644 index 000000000000..94e34697358c --- /dev/null +++ b/packages/google_adsense/lib/src/ad_unit_configuration.dart @@ -0,0 +1,112 @@ +// Copyright 2013 The Flutter 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/foundation.dart'; + +import '../experimental/google_adsense.dart'; + +/// AdUnit configuration object. +/// +/// Arguments: +/// - `adSlot`: See [AdUnitParams.AD_SLOT] +/// - `adFormat`: See [AdUnitParams.AD_FORMAT] +/// - `adLayout`: See [AdUnitParams.AD_LAYOUT] +/// - `adLayoutKey`: See [AdUnitParams.AD_LAYOUT_KEY] +/// - `multiplexLayout`: See [AdUnitParams.MATCHED_CONTENT_UI_TYPE] +/// - `rowsNum`: See [AdUnitParams.MATCHED_CONTENT_ROWS_NUM] +/// - `columnsNum`: See [AdUnitParams.MATCHED_CONTENT_COLUMNS_NUM] +/// - `isFullWidthResponsive`: See [AdUnitParams.FULL_WIDTH_RESPONSIVE] +/// - `isAdTest`: See [AdUnitParams.AD_TEST] +class AdUnitConfiguration { + AdUnitConfiguration._internal({ + required String adSlot, + AdFormat? adFormat, + AdLayout? adLayout, + String? adLayoutKey, + MatchedContentUiType? matchedContentUiType, + int? rowsNum, + int? columnsNum, + bool? isFullWidthResponsive = true, + bool? isAdTest, + }) : _adUnitParams = { + AdUnitParams.AD_SLOT: adSlot, + if (adFormat != null) AdUnitParams.AD_FORMAT: adFormat.toString(), + if (adLayout != null) AdUnitParams.AD_LAYOUT: adLayout.toString(), + if (adLayoutKey != null) AdUnitParams.AD_LAYOUT_KEY: adLayoutKey, + if (isFullWidthResponsive != null) + AdUnitParams.FULL_WIDTH_RESPONSIVE: + isFullWidthResponsive.toString(), + if (matchedContentUiType != null) + AdUnitParams.MATCHED_CONTENT_UI_TYPE: + matchedContentUiType.toString(), + if (columnsNum != null) + AdUnitParams.MATCHED_CONTENT_COLUMNS_NUM: columnsNum.toString(), + if (rowsNum != null) + AdUnitParams.MATCHED_CONTENT_ROWS_NUM: rowsNum.toString(), + if (isAdTest != null && isAdTest) AdUnitParams.AD_TEST: 'on', + }; + + /// Creates In-article ad unit configuration object + AdUnitConfiguration.multiplexAdUnit({ + required String adSlot, + required AdFormat adFormat, + MatchedContentUiType? matchedContentUiType, + int? rowsNum, + int? columnsNum, + bool isFullWidthResponsive = true, + bool isAdTest = kDebugMode, + }) : this._internal( + adSlot: adSlot, + adFormat: adFormat, + matchedContentUiType: matchedContentUiType, + rowsNum: rowsNum, + columnsNum: columnsNum, + isFullWidthResponsive: isFullWidthResponsive, + isAdTest: isAdTest); + + /// Creates In-feed ad unit configuration object + AdUnitConfiguration.inFeedAdUnit({ + required String adSlot, + required String adLayoutKey, + AdFormat? adFormat, + bool isFullWidthResponsive = true, + bool isAdTest = kDebugMode, + }) : this._internal( + adSlot: adSlot, + adFormat: adFormat, + adLayoutKey: adLayoutKey, + isFullWidthResponsive: isFullWidthResponsive, + isAdTest: isAdTest); + + /// Creates In-article ad unit configuration object + AdUnitConfiguration.inArticleAdUnit({ + required String adSlot, + AdFormat? adFormat, + AdLayout adLayout = AdLayout.IN_ARTICLE, + bool isFullWidthResponsive = true, + bool isAdTest = kDebugMode, + }) : this._internal( + adSlot: adSlot, + adFormat: adFormat, + adLayout: adLayout, + isFullWidthResponsive: isFullWidthResponsive, + isAdTest: isAdTest); + + /// Creates Display ad unit configuration object + AdUnitConfiguration.displayAdUnit({ + required String adSlot, + AdFormat? adFormat, + bool isFullWidthResponsive = true, + bool isAdTest = kDebugMode, + }) : this._internal( + adSlot: adSlot, + adFormat: adFormat, + isFullWidthResponsive: isFullWidthResponsive, + isAdTest: isAdTest); + + Map _adUnitParams; + + /// Map containing all additional parameters of this configuration + Map get params => _adUnitParams; +} diff --git a/packages/google_adsense/lib/src/ad_unit_params.dart b/packages/google_adsense/lib/src/ad_unit_params.dart new file mode 100644 index 000000000000..71a537fad8c4 --- /dev/null +++ b/packages/google_adsense/lib/src/ad_unit_params.dart @@ -0,0 +1,147 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Contains some of the possible adUnitParams keys constants for convenience and documentation +class AdUnitParams { + /// Identifies AdSense publisher account. Should be passed on initialization + static const String AD_CLIENT = 'adClient'; + + /// Identified specific ad unit from AdSense console. Can be taken from the ad unit HTML snippet under `data-ad-slot` parameter + static const String AD_SLOT = 'adSlot'; + + /// (Optional) Specify a general shape (desktop only) (horizontal, vertical, and/or rectangle) that your ad unit should conform to + /// See [docs](https://support.google.com/adsense/answer/9183460?hl=en&ref_topic=9183242&sjid=2004567335727763076-EU#:~:text=Specify%20a%20general%20shape%20(desktop%20only)) for details + static const String AD_FORMAT = 'adFormat'; + + /// (Optional) The data-full-width-responsive parameter determines whether your responsive ad unit expands to use the full width of your visitor's mobile device screen. + /// See [docs](https://support.google.com/adsense/answer/9183460?hl=en&ref_topic=9183242&sjid=2004567335727763076-EU#:~:text=Set%20the%20behavior%20of%20full%2Dwidth%20responsive%20ads%20on%20mobile%20devices) for details + static const String FULL_WIDTH_RESPONSIVE = 'fullWidthResponsive'; + + /// (Optional) Use value provided in the AdSense code generated in AdSense Console + static const String AD_LAYOUT_KEY = 'adLayoutKey'; + + /// (Optional) Use value provided in the AdSense code generated in AdSense Console + static const String AD_LAYOUT = 'adLayout'; + + /// (Optional) This parameter lets you control the arrangement of the text and images in your Multiplex ad units. For example, you can choose to have the image and text side by side, the image above the text, etc. + /// See [MultiplexLayout] + /// See [docs](https://support.google.com/adsense/answer/7533385?hl=en#:~:text=Change%20the%20layout%20of%20your%20Multiplex%20ad%20unit) + static const String MATCHED_CONTENT_UI_TYPE = 'matchedContentUiType'; + + /// The ads inside a Multiplex ad unit are arranged in a grid. You can specify how many rows and columns you want to show within that grid
+ /// Sets the number of rows
+ /// Requires setting [AdUnitParams.MATCHED_CONTENT_UI_TYPE] + static const String MATCHED_CONTENT_ROWS_NUM = 'macthedContentRowsNum'; + + /// The ads inside a Multiplex ad unit are arranged in a grid. You can specify how many rows and columns you want to show within that grid
+ /// Sets the number of columns
+ /// Requires setting [AdUnitParams.MATCHED_CONTENT_UI_TYPE] + static const String MATCHED_CONTENT_COLUMNS_NUM = 'macthedContentColumnsNum'; + + /// testing environment flag, defaults to kIsDebug + static const String AD_TEST = 'adtest'; +} + +/// Possible values for [AdUnitParams.AD_FORMAT]. +/// +/// See [docs](https://support.google.com/adsense/answer/9183460?hl=en&ref_topic=9183242&sjid=2004567335727763076-EU#:~:text=Specify%20a%20general%20shape%20(desktop%20only)) for details +enum AdFormat { + /// Default which enables the auto-sizing behavior for the responsive ad unit + AUTO('auto'), + + /// Use horizontal shape + HORIZONTAL('horizontal'), + + /// Use rectangle shape + RECTANGLE('rectangle'), + + /// Use vertical shape + VERTICAL('vertical'), + + /// Use horizontal and rectangle shape + HORIZONTAL_RECTANGLE('horizontal,rectangle'), + + /// Use horizontal and vertical shape + HORIZONTAL_VERTICAL('horizontal,vertical'), + + /// Use rectangle and vertical shape + RECTANGLE_VERTICAL('rectangle,vertical'), + + /// Use horizontal, rectangle and vertical shape + HORIZONTAL_RECTANGLE_VERTICAL('horizontal,rectangle,vertical'), + + /// Fluid ads have no fixed size, but rather adapt to fit the creative content they display + FLUID('fluid'); + + const AdFormat(this._adFormat); + final String _adFormat; + + @override + String toString() => _adFormat; +} + +/// Possible values for [AdUnitParams.AD_LAYOUT]. +/// +// TODO(sokoloff06): find docs link! +enum AdLayout { + /// + IMAGE_TOP('image-top'), + + /// + IMAGE_MIDDLE('image-middle'), + + /// + IMAGE_SIDE('image-side'), + + /// + TEXT_ONLY('text-only'), + + /// + IN_ARTICLE('in-article'); + + const AdLayout(this._adLayout); + final String _adLayout; + + @override + String toString() => _adLayout; +} + +/// Possible values for [AdUnitParams.MATCHED_CONTENT_UI_TYPE]. +/// +/// See [docs](https://support.google.com/adsense/answer/7533385?hl=en#:~:text=Change%20the%20layout%20of%20your%20Multiplex%20ad%20unit) +enum MatchedContentUiType { + /// In this layout, the image and text appear alongside each other. + IMAGE_CARD_SIDEBYSIDE('image_card_sidebyside'), + + /// In this layout, the image and text appear alongside each other within a card. + IMAGE_SIDEBYSIDE('image_sidebyside'), + + /// In this layout, the image and text are arranged one on top of the other. + IMAGE_STACKED('image_stacked'), + + /// In this layout, the image and text are arranged one on top of the other within a card. + IMAGE_CARD_STACKED('image_card_stacked'), + + /// A text-only layout with no image. + TEXT('text'), + + /// A text-only layout within a card. + TEXT_CARD('text_card'); + + const MatchedContentUiType(this._uiType); + final String _uiType; + + @override + String toString() => _uiType; +} + +/// After an ad unit has finished requesting an ad, AdSense adds a parameter to the element called data-ad-status. Note: data-ad-status should not be confused with data-adsbygoogle-status, which is used by our ad code for ads processing purposes. +/// See [docs](https://support.google.com/adsense/answer/10762946?hl=en) for more information +class AdStatus { + /// Indicates ad slot was filled + static const String FILLED = 'filled'; + + /// Indicates ad slot was not filled + static const String UNFILLED = 'unfilled'; +} diff --git a/packages/google_adsense/lib/src/ad_unit_widget.dart b/packages/google_adsense/lib/src/ad_unit_widget.dart new file mode 100644 index 000000000000..97d42fbca45a --- /dev/null +++ b/packages/google_adsense/lib/src/ad_unit_widget.dart @@ -0,0 +1,166 @@ +// Copyright 2013 The Flutter 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:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/widgets.dart'; +import 'package:web/web.dart' as web; + +import '../experimental/google_adsense.dart'; +import 'js_interop/adsbygoogle.dart'; +import 'logging.dart'; + +/// Widget displaying an ad unit +class AdUnitWidget extends StatefulWidget { + /// Constructs [AdUnitWidget] + const AdUnitWidget({ + super.key, + required String adClient, + required AdUnitConfiguration configuration, + }) : _adClient = adClient, + _adUnitConfiguration = configuration; + + final String _adClient; + + final AdUnitConfiguration _adUnitConfiguration; + + @override + State createState() => _AdUnitWidgetWebState(); +} + +class _AdUnitWidgetWebState extends State + with AutomaticKeepAliveClientMixin { + static int _adUnitCounter = 0; + static final JSString _adStatusKey = 'adStatus'.toJS; + + // Limit height to minimize layout shift in case ad is not loaded + Size _adSize = const Size(double.infinity, 1.0); + + @override + bool get wantKeepAlive => true; + + static final web.ResizeObserver _adSenseResizeObserver = web.ResizeObserver( + (JSArray entries, web.ResizeObserver observer) { + for (final web.ResizeObserverEntry entry in entries.toDart) { + final web.Element target = entry.target; + if (target.isConnected) { + // First time resized since attached to DOM -> attachment callback from Flutter docs by David + _onElementAttached(target as web.HTMLElement); + observer.disconnect(); + } + } + }.toJS); + + @override + Widget build(BuildContext context) { + super.build(context); + // If the ad is collapsed (0x0), return an empty widget + if (_adSize.isEmpty) { + return const SizedBox.shrink(); + } + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (!widget._adUnitConfiguration.params + .containsKey(AdUnitParams.AD_FORMAT)) { + _adSize = Size(_adSize.width, constraints.maxHeight); + } + return SizedBox( + height: _adSize.height, + width: _adSize.width, + child: HtmlElementView.fromTagName( + tagName: 'div', + onElementCreated: _onElementCreated, + ), + ); + }); + } + + void _onElementCreated(Object element) { + // Create the `ins` element that is going to contain the actual ad. + final web.HTMLElement insElement = + (web.document.createElement('ins') as web.HTMLElement) + ..className = 'adsbygoogle' + ..style.width = '100%' + ..style.height = '100%' + ..style.display = 'block'; + + // Apply the widget configuration to insElement + { + AdUnitParams.AD_CLIENT: 'ca-pub-${widget._adClient}', + ...widget._adUnitConfiguration.params, + }.forEach((String key, String value) { + insElement.dataset.setProperty(key.toJS, value.toJS); + }); + + // Adding ins inside of the adUnit + final web.HTMLDivElement adUnitDiv = element as web.HTMLDivElement + ..id = 'adUnit${_adUnitCounter++}' + ..append(insElement); + + // Using Resize observer to detect element attached to DOM + _adSenseResizeObserver.observe(adUnitDiv); + + // Using Mutation Observer to detect when adslot is being loaded based on https://support.google.com/adsense/answer/10762946?hl=en + web.MutationObserver( + (JSArray entries, web.MutationObserver observer) { + for (final JSObject entry in entries.toDart) { + final web.HTMLElement target = + (entry as web.MutationRecord).target as web.HTMLElement; + if (_isLoaded(target)) { + if (_isFilled(target)) { + debugLog( + 'Resizing widget based on target $target size of ${target.offsetWidth} x ${target.offsetHeight}'); + _updateWidgetSize(Size( + target.offsetWidth.toDouble(), + // This is always the width of the platform view! + target.offsetHeight.toDouble(), + )); + } else { + // This removes the platform view. + _updateWidgetSize(Size.zero); + } + } + } + }.toJS) + .observe( + insElement, + web.MutationObserverInit( + attributes: true, + attributeFilter: ['data-ad-status'.toJS].toJS, + )); + } + + static void _onElementAttached(web.HTMLElement element) { + debugLog( + '$element attached with w=${element.offsetWidth} and h=${element.offsetHeight}'); + debugLog( + '${element.firstChild} size is ${(element.firstChild! as web.HTMLElement).offsetWidth}x${(element.firstChild! as web.HTMLElement).offsetHeight} '); + adsbygoogle.requestAd(); + } + + bool _isLoaded(web.HTMLElement target) { + final bool isLoaded = + target.dataset.getProperty(_adStatusKey).isDefinedAndNotNull; + debugLog('Ad isLoaded: $isLoaded'); + return isLoaded; + } + + bool _isFilled(web.HTMLElement target) { + final String? adStatus = + target.dataset.getProperty(_adStatusKey)?.toDart; + debugLog('Ad isFilled? $adStatus'); + if (adStatus == AdStatus.FILLED) { + return true; + } + return false; + } + + void _updateWidgetSize(Size newSize) { + debugLog('Resizing AdUnitWidget to $newSize'); + setState(() { + _adSize = newSize; + }); + } +} diff --git a/packages/google_adsense/lib/src/adsense_stub.dart b/packages/google_adsense/lib/src/adsense_stub.dart new file mode 100644 index 000000000000..d01bc3304445 --- /dev/null +++ b/packages/google_adsense/lib/src/adsense_stub.dart @@ -0,0 +1,28 @@ +// Copyright 2013 The Flutter 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/widgets.dart'; + +import '../experimental/google_adsense.dart'; + +/// A singleton instance of AdSense library public interface. +final AdSense adSense = AdSense(); + +/// AdSense package interface. +class AdSense { + /// Initialization API. Should be called ASAP, ideally in the main method of your app. + void initialize( + String adClient, { + @visibleForTesting bool skipJsLoader = false, + @visibleForTesting Object? jsLoaderTarget, + }) { + throw UnsupportedError('Only supported on web'); + } + + /// Returns a configurable [AdUnitWidget]
+ /// `configuration`: see [AdUnitConfiguration] + Widget adUnit(AdUnitConfiguration configuration) { + throw UnsupportedError('Only supported on web'); + } +} diff --git a/packages/google_adsense/lib/src/adsense_web.dart b/packages/google_adsense/lib/src/adsense_web.dart new file mode 100644 index 000000000000..79434b1b616c --- /dev/null +++ b/packages/google_adsense/lib/src/adsense_web.dart @@ -0,0 +1,93 @@ +// Copyright 2013 The Flutter 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:js_interop'; + +import 'package:flutter/widgets.dart'; +import 'package:web/web.dart' as web; + +import 'ad_unit_configuration.dart'; +import 'ad_unit_widget.dart'; +import 'js_interop/adsbygoogle.dart' show adsbygooglePresent; +import 'js_interop/package_web_tweaks.dart'; + +import 'logging.dart'; + +/// Returns a singleton instance of Adsense library public interface +final AdSense adSense = AdSense(); + +/// Main class to work with the library +class AdSense { + bool _isInitialized = false; + + /// The ad client ID used by this client. + late String _adClient; + static const String _url = + 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-'; + + /// Initializes the AdSense SDK with your [adClient]. + /// + /// Should be called ASAP, ideally in the main method of your app. + /// + /// Noops after the first call. + void initialize( + String adClient, { + @visibleForTesting bool skipJsLoader = false, + @visibleForTesting web.HTMLElement? jsLoaderTarget, + }) { + if (_isInitialized) { + debugLog('adSense.initialize called multiple times. Skipping init.'); + return; + } + _adClient = adClient; + if (!(skipJsLoader || _sdkAlreadyLoaded(testingTarget: jsLoaderTarget))) { + _loadJsSdk(_adClient, jsLoaderTarget); + } else { + debugLog('SDK already on page. Skipping init.'); + } + _isInitialized = true; + } + + /// Returns an [AdUnitWidget] with the specified [configuration]. + Widget adUnit(AdUnitConfiguration configuration) { + return AdUnitWidget(adClient: _adClient, configuration: configuration); + } + + bool _sdkAlreadyLoaded({ + web.HTMLElement? testingTarget, + }) { + final String selector = 'script[src*=ca-pub-$_adClient]'; + return adsbygooglePresent || + web.document.querySelector(selector) != null || + testingTarget?.querySelector(selector) != null; + } + + void _loadJsSdk(String adClient, web.HTMLElement? testingTarget) { + final String finalUrl = _url + adClient; + + final web.HTMLScriptElement script = web.HTMLScriptElement() + ..async = true + ..crossOrigin = 'anonymous'; + + if (web.window.nullableTrustedTypes != null) { + final String trustedTypePolicyName = 'adsense-dart-$adClient'; + try { + final web.TrustedTypePolicy policy = + web.window.trustedTypes.createPolicy( + trustedTypePolicyName, + web.TrustedTypePolicyOptions( + createScriptURL: ((JSString url) => url).toJS, + )); + script.trustedSrc = policy.createScriptURLNoArgs(finalUrl); + } catch (e) { + throw TrustedTypesException(e.toString()); + } + } else { + debugLog('TrustedTypes not available.'); + script.src = finalUrl; + } + + (testingTarget ?? web.document.head)!.appendChild(script); + } +} diff --git a/packages/google_adsense/lib/src/js_interop/adsbygoogle.dart b/packages/google_adsense/lib/src/js_interop/adsbygoogle.dart new file mode 100644 index 000000000000..1cdf98d6eadc --- /dev/null +++ b/packages/google_adsense/lib/src/js_interop/adsbygoogle.dart @@ -0,0 +1,44 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@JS() +library; + +import 'dart:js_interop'; + +/// JS-interop mappings to the window.adsbygoogle object. +extension type AdsByGoogle._(JSObject _) implements JSObject { + @JS('push') + external void _push(JSObject params); +} + +/// Convenience methods for Dart users. +extension AdsByGoogleExtension on AdsByGoogle { + /// Convenience method for invoking push() with an empty object + void requestAd() { + _push(JSObject()); + } +} + +// window.adsbygoogle may be null if this package runs before the JS SDK loads. +@JS('adsbygoogle') +external AdsByGoogle? get _adsbygoogle; + +// window.adsbygoogle uses "duck typing", so let us set anything to it. +@JS('adsbygoogle') +external set _adsbygoogle(JSAny? value); + +/// Whether or not the `window.adsbygoogle` object is defined and not null. +bool get adsbygooglePresent => _adsbygoogle.isDefinedAndNotNull; + +/// Binding to the `adsbygoogle` JS global. +/// +/// See: https://support.google.com/adsense/answer/9274516?hl=en&ref_topic=28893&sjid=11495822575537499409-EU +AdsByGoogle get adsbygoogle { + if (!adsbygooglePresent) { + // Initialize _adsbygoole to "something that has a push method". + _adsbygoogle = JSArray(); + } + return _adsbygoogle!; +} diff --git a/packages/google_adsense/lib/src/js_interop/package_web_tweaks.dart b/packages/google_adsense/lib/src/js_interop/package_web_tweaks.dart new file mode 100644 index 000000000000..7f819dc6b9c5 --- /dev/null +++ b/packages/google_adsense/lib/src/js_interop/package_web_tweaks.dart @@ -0,0 +1,47 @@ +// Copyright 2013 The Flutter 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:js_interop'; + +import 'package:web/web.dart' as web; + +// Re-use of https://github.com/flutter/packages/blob/main/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart + +/// This extension gives web.window a nullable getter to the `trustedTypes` +/// property, which needs to be used to check for feature support. +extension NullableTrustedTypesGetter on web.Window { + /// (Nullable) Bindings to window.trustedTypes. + /// + /// This may be null if the browser doesn't support the Trusted Types API. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API + @JS('trustedTypes') + external web.TrustedTypePolicyFactory? get nullableTrustedTypes; +} + +/// Allows setting a TrustedScriptURL as the src of a script element. +extension TrustedTypeSrcAttribute on web.HTMLScriptElement { + @JS('src') + external set trustedSrc(web.TrustedScriptURL value); +} + +/// Allows creating a script URL only from a string, with no arguments. +extension CreateScriptUrlNoArgs on web.TrustedTypePolicy { + /// Allows calling `createScriptURL` with only the `input` argument. + @JS('createScriptURL') + external web.TrustedScriptURL createScriptURLNoArgs(String input); +} + +/// Exception thrown if the Trusted Types feature is supported, enabled, and it +/// has prevented this loader from injecting the JS SDK. +class TrustedTypesException implements Exception { + /// + TrustedTypesException(this.message); + + /// The message of the exception + final String message; + + @override + String toString() => 'TrustedTypesException: $message'; +} diff --git a/packages/google_adsense/lib/src/logging.dart b/packages/google_adsense/lib/src/logging.dart new file mode 100644 index 000000000000..5b717bd0ed3c --- /dev/null +++ b/packages/google_adsense/lib/src/logging.dart @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter 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:js_interop'; + +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:web/web.dart' as web; + +/// Logs [log] to the JS console with debug level, if [kDebugMode] is `true`. +void debugLog(String log) { + if (kDebugMode) { + web.console.debug('[google_adsense] $log'.toJS); + } +} diff --git a/packages/google_adsense/pubspec.yaml b/packages/google_adsense/pubspec.yaml new file mode 100644 index 000000000000..e9db445d5121 --- /dev/null +++ b/packages/google_adsense/pubspec.yaml @@ -0,0 +1,28 @@ +name: google_adsense +description: A wrapper plugin with convenience APIs allowing easier inserting Google Adsense HTML snippets withing a Flutter UI Web application +repository: https://github.com/flutter/packages/tree/main/packages/google_adsense +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_adsense%22 +version: 0.0.1 + +environment: + sdk: ^3.4.0 + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + web: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +topics: + - monetization + - ads + +screenshots: + - description: 'Ad displayed in the example app on the desktop browser' + path: example/images/desktop_screenshot.jpg + - description: 'Ad displayed in the example app on the mobile browser' + path: example/images/mobile_screenshot.png \ No newline at end of file diff --git a/packages/google_adsense/test/adsense_stub_test.dart b/packages/google_adsense/test/adsense_stub_test.dart new file mode 100644 index 000000000000..24e450e7ca78 --- /dev/null +++ b/packages/google_adsense/test/adsense_stub_test.dart @@ -0,0 +1,16 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Tell the user where to find the real tests', () { + print('---'); + print('This package uses integration_test for its main tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +}