Skip to main content

wherefrom 0.4.0 copy "wherefrom: ^0.4.0" to clipboard
wherefrom: ^0.4.0 copied to clipboard

Flutter plugin: Play Install Referrer (Android) and Apple AdServices (iOS) for install attribution and deferred deep links, with typed results and first-launch-only reads.

example/lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:wherefrom/wherefrom.dart';

void main() => runApp(const ExampleApp());

/// Paths this app is willing to open from a referrer. Anything else in
/// `utm_content` is ignored.
const allowedPaths = ['/items/', '/profile/'];

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Install attribution',
    theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
    home: const AttributionPage(),
  );
}

class AttributionPage extends StatefulWidget {
  const AttributionPage({super.key});

  @override
  State<AttributionPage> createState() => _AttributionPageState();
}

class _AttributionPageState extends State<AttributionPage> {
  InstallAttributionResult? _result;
  InstallSource? _source;
  bool? _handled;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _read();
  }

  Future<void> _read() async {
    setState(() => _loading = true);
    final result = await InstallAttribution.read(
      timeout: const Duration(seconds: 10),
    );
    final source = await InstallAttribution.installSource();
    final handled = await InstallAttribution.isHandled();
    // Also read by CI's release smoke test, which checks a release build
    // reaches the plugin after R8.
    debugPrint('read(): $result');
    debugPrint('installSource(): $source');
    if (!mounted) return;
    setState(() {
      _result = result;
      _source = source;
      _handled = handled;
      _loading = false;
    });
  }

  void _simulate({String? referrer, InstallAttributionResult? result}) {
    InstallAttribution.debugOverride(
      referrer: referrer,
      result: result,
      clickedAt: DateTime.now().subtract(const Duration(minutes: 3)),
    );
    _read();
  }

  Future<void> _markHandled() async {
    await InstallAttribution.markHandled();
    await _read();
  }

  Future<void> _clearHandled() async {
    await InstallAttribution.clearHandled();
    await _read();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Where did this install come from?')),
      body: RefreshIndicator(
        onRefresh: _read,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            if (_loading) const LinearProgressIndicator(),
            _ResultCard(result: _result),
            const SizedBox(height: 12),
            _InfoTile(
              title: 'Install source',
              value: _source == null
                  ? 'unknown (iOS, or not readable)'
                  : '${_source!.store.name} '
                        '(${_source!.installerPackage ?? 'no installer'})',
            ),
            _InfoTile(
              title: 'Consume-once marker',
              value: _handled == true ? 'handled' : 'not handled',
            ),
            Wrap(
              spacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _markHandled,
                  child: const Text('markHandled()'),
                ),
                OutlinedButton(
                  onPressed: _clearHandled,
                  child: const Text('clearHandled()'),
                ),
              ],
            ),
            if (kDebugMode) ...[
              const Divider(height: 32),
              Text(
                'Simulate (debug builds only)',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                runSpacing: 8,
                children: [
                  OutlinedButton(
                    onPressed: () => _simulate(
                      referrer:
                          'utm_source=newsletter&utm_medium=email'
                          '&utm_content=%2Fitems%2F42',
                    ),
                    child: const Text('Attributed + deep link'),
                  ),
                  OutlinedButton(
                    onPressed: () => _simulate(
                      referrer: 'utm_source=evil&utm_content=%2Fadmin',
                    ),
                    child: const Text('Link outside allowlist'),
                  ),
                  OutlinedButton(
                    onPressed: () => _simulate(
                      referrer: 'utm_source=google-play&utm_medium=organic',
                    ),
                    child: const Text('Organic'),
                  ),
                  OutlinedButton(
                    onPressed: () => _simulate(
                      referrer: 'utm_source=(not%20set)&utm_medium=(not%20set)',
                    ),
                    child: const Text('(not set)'),
                  ),
                  OutlinedButton(
                    onPressed: () => _simulate(
                      result: const Unavailable(
                        UnavailableReason.timeout,
                        message: 'simulated',
                      ),
                    ),
                    child: const Text('Unavailable(timeout)'),
                  ),
                  TextButton(
                    onPressed: () {
                      InstallAttribution.debugClearOverride();
                      _read();
                    },
                    child: const Text('Real read'),
                  ),
                ],
              ),
            ],
            const Divider(height: 32),
            _InfoTile(
              title: 'Play URL that would produce the first simulation',
              value: PlayStoreUrl.web(
                packageName: 'io.github.saimskywalker.wherefrom_example',
                referrer: const {
                  'utm_source': 'newsletter',
                  'utm_medium': 'email',
                  'utm_content': '/items/42',
                },
              ).toString(),
            ),
          ],
        ),
      ),
    );
  }
}

class _ResultCard extends StatelessWidget {
  const _ResultCard({required this.result});

  final InstallAttributionResult? result;

  @override
  Widget build(BuildContext context) {
    final (String title, List<String> lines) = switch (result) {
      null => ('Reading…', const <String>[]),
      Attributed(source: AttributionSource.appleAds, :final token) => (
        'Attributed (Apple Ads)',
        ['token: ${token?.substring(0, 24)}… (send it to your server)'],
      ),
      Attributed(:final referrer, :final clickedAt) => (
        'Attributed (Google Play)',
        [
          'referrer: ${referrer?.raw}',
          'utm_source: ${referrer?.utm.source}',
          'clicked at: $clickedAt',
          'deep link: ${referrer?.deepLink(allow: allowedPaths) ?? 'none (not allowlisted)'}',
        ],
      ),
      Organic(:final referrer) => (
        'Organic',
        ['referrer: ${referrer?.raw ?? '(empty)'}'],
      ),
      Unavailable(:final reason, :final message) => (
        'Unavailable(${reason.name})',
        [?message],
      ),
    };
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: Theme.of(context).textTheme.titleLarge),
            for (final line in lines)
              Padding(
                padding: const EdgeInsets.only(top: 6),
                child: SelectableText(line),
              ),
          ],
        ),
      ),
    );
  }
}

class _InfoTile extends StatelessWidget {
  const _InfoTile({required this.title, required this.value});

  final String title;
  final String value;

  @override
  Widget build(BuildContext context) => ListTile(
    contentPadding: EdgeInsets.zero,
    title: Text(title),
    subtitle: SelectableText(value),
  );
}
0
likes
160
points
639
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter plugin: Play Install Referrer (Android) and Apple AdServices (iOS) for install attribution and deferred deep links, with typed results and first-launch-only reads.

Repository (GitHub)
View/report issues
Contributing

Topics

#flutter #android #ios #deep-linking #attribution

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on wherefrom

Packages that implement wherefrom