Flutter SDK - Uninstall Tracking

Uninstall Tracking

Track app uninstalls to measure user retention and optimize re-engagement campaigns by integrating push notification services with the Singular SDK.

Important: Google deprecated GCM APIs in April 2018. Use Firebase Cloud Messaging (FCM) for all Android uninstall tracking implementations.


Android Uninstall Tracking

Prerequisites

Before implementing uninstall tracking in your Flutter app, configure your app in the Singular platform following the guide Setting Up Android Uninstall Tracking.


System Requirements

Uninstall tracking requires Firebase Cloud Messaging and specific device configurations.

FCM Requirements (source):

  • Android Version: Devices must run Android 4.1 (API 16) or higher
  • Google Play Services: Devices must have the Google Play Store app installed
  • Emulator Support: Android 4.1+ emulators with Google APIs are supported
  • Distribution: Apps can be distributed outside the Google Play Store while still supporting uninstall tracking

Note: Users on unsupported Android versions or devices without Google Play Services will not be tracked for uninstalls.


Implementation Steps

Step 1: Install Firebase Packages

Add Firebase dependencies to your pubspec.yaml file for core functionality and messaging support.

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.2
  firebase_messaging: ^14.7.10
  singular_flutter_sdk: ^1.8.0

After adding dependencies, run flutter pub get to install the packages.


Step 2: Configure Firebase

Add Firebase configuration files to your Flutter project for Android.

  1. Register your Android app in your Firebase Console project
  2. Download google-services.json and place it in android/app/
  3. Verify Firebase messaging dependencies are added to your project

For detailed setup instructions, see Add Firebase to your Flutter app.


Step 3: Initialize Firebase

Initialize Firebase before running your Flutter app to enable messaging services.

Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Firebase
  await Firebase.initializeApp();

  runApp(MyApp());
}

Step 4: Request Notification Permission

Request notification permissions from the user (required for Android 13+) before retrieving the FCM token.

Dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

Future<bool> requestNotificationPermission() async {
  if (Platform.isAndroid) {
    // Android 13+ requires explicit permission request
    // Note: firebase_messaging handles this automatically via requestPermission
    final FirebaseMessaging messaging = FirebaseMessaging.instance;
    final NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    return settings.authorizationStatus == AuthorizationStatus.authorized ||
           settings.authorizationStatus == AuthorizationStatus.provisional;
  }

  // iOS permission handled separately
  return true;
}

Step 5: Retrieve and Register FCM Token

Get the FCM device token and register it with Singular using registerDeviceTokenForUninstall() after requesting permissions.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();

    if (Platform.isAndroid) {
      initializeAndroidUninstallTracking();
    }
  }

  Future<void> initializeAndroidUninstallTracking() async {
    try {
      // Request notification permission
      final hasPermission = await requestNotificationPermission();

      if (!hasPermission) {
        print('Notification permission denied - uninstall tracking unavailable');
        return;
      }

      // Get FCM token
      final token = await FirebaseMessaging.instance.getToken();

      if (token != null) {
        // Register token with Singular for uninstall tracking
        Singular.registerDeviceTokenForUninstall(token);
        print('FCM token registered with Singular: $token');
      } else {
        print('No FCM token available');
      }
    } catch (error) {
      print('Error setting up uninstall tracking: $error');
    }
  }

  Future<bool> requestNotificationPermission() async {
    final messaging = FirebaseMessaging.instance;
    final settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    return settings.authorizationStatus == AuthorizationStatus.authorized ||
           settings.authorizationStatus == AuthorizationStatus.provisional;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

Method Signature:

static void registerDeviceTokenForUninstall(String token)

For complete method documentation, see registerDeviceTokenForUninstall reference.


Step 6: Handle Token Refresh

Update the FCM token with Singular whenever it refreshes to maintain accurate uninstall tracking.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();

    if (Platform.isAndroid) {
      setupTokenRefreshListener();
    }
  }

  void setupTokenRefreshListener() {
    // Listen for token refresh events
    FirebaseMessaging.instance.onTokenRefresh.listen((String token) {
      print('FCM token refreshed: $token');

      // Update Singular with new token
      Singular.registerDeviceTokenForUninstall(token);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

Best Practice: FCM tokens can refresh at any time (app updates, device restore, etc.). Always subscribe to the onTokenRefresh stream to keep Singular updated with the latest token.


iOS Uninstall Tracking

Prerequisites

Configure your iOS app in the Singular platform following the guide Setting Up iOS Uninstall Tracking.

Uninstall tracking on iOS is based on Apple Push Notification service (APNs) technology. If your app doesn't support push notifications, see Apple's guide to Registering Your App with APNs.


Implementation Steps

Step 1: Configure iOS Project

Add Firebase configuration and enable push notification capabilities in your iOS project.

  1. Register your iOS app in your Firebase Console project
  2. Download GoogleService-Info.plist and add it to the Xcode Runner folder
  3. In Xcode project settings, enable Push Notifications capability
  4. Enable Background Modes and check Remote notifications

Step 2: Request iOS Notification Authorization

Request notification permissions from the user and retrieve the APNS device token.

Dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

Future<bool> requestIOSNotificationPermission() async {
  if (!Platform.isIOS) {
    return false;
  }

  try {
    final FirebaseMessaging messaging = FirebaseMessaging.instance;
    final NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );

    final authorized =
        settings.authorizationStatus == AuthorizationStatus.authorized ||
        settings.authorizationStatus == AuthorizationStatus.provisional;

    if (authorized) {
      print('iOS notification authorization status: ${settings.authorizationStatus}');
      return true;
    }

    return false;
  } catch (error) {
    print('Error requesting iOS notification permission: $error');
    return false;
  }
}

Step 3: Retrieve and Register APNS Token

Get the APNS device token and register it with Singular using registerDeviceTokenForUninstall() after authorization is granted.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();

    if (Platform.isIOS) {
      initializeIOSUninstallTracking();
    }
  }

  Future<void> initializeIOSUninstallTracking() async {
    try {
      // Request notification authorization
      final hasPermission = await requestIOSNotificationPermission();

      if (!hasPermission) {
        print('Notification permission denied - uninstall tracking unavailable');
        return;
      }

      // Get APNS token
      final apnsToken = await FirebaseMessaging.instance.getAPNSToken();

      if (apnsToken != null) {
        // Register token with Singular for uninstall tracking
        Singular.registerDeviceTokenForUninstall(apnsToken);
        print('APNS token registered with Singular: $apnsToken');
      } else {
        print('No APNS token available');
      }
    } catch (error) {
      print('Error setting up iOS uninstall tracking: $error');
    }
  }

  Future<bool> requestIOSNotificationPermission() async {
    final messaging = FirebaseMessaging.instance;
    final settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    return settings.authorizationStatus == AuthorizationStatus.authorized ||
           settings.authorizationStatus == AuthorizationStatus.provisional;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

Token Format: The APNS token retrieved from getAPNSToken() is already formatted as a hexadecimal string, which is the correct format for Singular.


Step 4: Handle Token Refresh (iOS)

Update the APNS token with Singular if it changes during the app lifecycle.

Dart
import 'package:flutter/material.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();

    if (Platform.isIOS) {
      setupIOSTokenRefreshListener();
    }
  }

  void setupIOSTokenRefreshListener() {
    // Listen for token refresh events
    FirebaseMessaging.instance.onTokenRefresh.listen((String token) {
      print('APNS token refreshed: $token');

      // Update Singular with new token
      Singular.registerDeviceTokenForUninstall(token);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

Complete Cross-Platform Implementation

Unified Uninstall Tracking Setup

Implement uninstall tracking for both Android and iOS platforms with proper error handling and token refresh logic.

Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';
import 'dart:io';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Firebase
  await Firebase.initializeApp();

  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();

    // Initialize Singular SDK
    initializeSingularSDK();

    // Setup uninstall tracking
    initializeUninstallTracking();

    // Setup token refresh listener
    setupTokenRefreshListener();
  }

  void initializeSingularSDK() {
    final config = SingularConfig(
      'YOUR_SDK_KEY',
      'YOUR_SDK_SECRET'
    );

    Singular.start(config);
  }

  Future<void> initializeUninstallTracking() async {
    try {
      if (Platform.isAndroid) {
        await setupAndroidUninstallTracking();
      } else if (Platform.isIOS) {
        await setupIOSUninstallTracking();
      }
    } catch (error) {
      print('Error initializing uninstall tracking: $error');
    }
  }

  Future<void> setupAndroidUninstallTracking() async {
    print('Setting up Android uninstall tracking');

    // Request notification permission
    final messaging = FirebaseMessaging.instance;
    final settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    if (settings.authorizationStatus != AuthorizationStatus.authorized &&
        settings.authorizationStatus != AuthorizationStatus.provisional) {
      print('Android notification permission denied');
      return;
    }

    // Get and register FCM token
    final token = await messaging.getToken();

    if (token != null) {
      Singular.registerDeviceTokenForUninstall(token);
      print('Android FCM token registered: $token');
    } else {
      print('Failed to retrieve Android FCM token');
    }
  }

  Future<void> setupIOSUninstallTracking() async {
    print('Setting up iOS uninstall tracking');

    // Request iOS notification authorization
    final messaging = FirebaseMessaging.instance;
    final settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    final authorized =
        settings.authorizationStatus == AuthorizationStatus.authorized ||
        settings.authorizationStatus == AuthorizationStatus.provisional;

    if (!authorized) {
      print('iOS notification permission denied');
      return;
    }

    // Get and register APNS token
    final apnsToken = await messaging.getAPNSToken();

    if (apnsToken != null) {
      Singular.registerDeviceTokenForUninstall(apnsToken);
      print('iOS APNS token registered: $apnsToken');
    } else {
      print('Failed to retrieve iOS APNS token');
    }
  }

  void setupTokenRefreshListener() {
    // Listen for token refresh events (works for both platforms)
    FirebaseMessaging.instance.onTokenRefresh.listen((String token) {
      print('${Platform.operatingSystem.toUpperCase()} token refreshed: $token');
      Singular.registerDeviceTokenForUninstall(token);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Uninstall Tracking Demo'),
        ),
        body: Center(
          child: Text('Uninstall tracking initialized'),
        ),
      ),
    );
  }
}

Platform-Specific Notes

  • iOS: Ensure your app has the necessary push notification entitlements and APNs is properly configured in your Apple Developer account
  • Android: Verify that FCM is set up in your Firebase console and the google-services.json file is included in your project at android/app/

Verification and Troubleshooting

Verify Implementation

Confirm uninstall tracking is working correctly before deploying to production.

  1. Check Logs: Verify token registration appears in your console logs with the correct format
  2. Test Token Generation: Ensure tokens are generated on first app launch after granting permissions
  3. Monitor Dashboard: Check Singular dashboard for uninstall tracking data after 24-48 hours
  4. Test Token Refresh: Clear app data and verify token updates correctly when app relaunches

Common Issues

  • Token Not Generated: Verify Firebase dependencies are correctly installed and Firebase is configured in your Flutter project. Run flutter pub get after adding dependencies
  • Permission Denied: Check that users have granted notification permissions. For Android 13+, explicit permission request is required. For iOS, users must authorize notifications
  • Token Not Updating: Ensure you've subscribed to the onTokenRefresh stream for both platforms. The listener should be set up in your app's initialization
  • Missing Data: Confirm devices meet platform requirements (Android 4.1+ with Google Play Services, iOS with APNs support). Devices without these services cannot be tracked
  • Configuration Error: Verify uninstall tracking is enabled in Singular platform settings for your app. Follow the platform-specific setup guides linked in Prerequisites
  • Firebase Setup: For Android, ensure google-services.json is in android/app/. For iOS, ensure GoogleService-Info.plist is added to Xcode project Runner folder
  • SDK Initialization: Confirm the Singular SDK is initialized before calling registerDeviceTokenForUninstall(). The token registration should happen after SDK startup
  • Platform Detection: Use Platform.isAndroid and Platform.isIOS from dart:io to ensure platform-specific code runs on the correct platform

Best Practices

Token Management

  • Early Registration: Request permissions and register tokens as early as possible in the app lifecycle, ideally during first app launch
  • Error Handling: Implement robust error handling around token retrieval and registration to gracefully handle failures
  • Token Refresh: Always implement token refresh listeners to keep Singular updated when tokens change
  • User Experience: Request notification permissions in context, explaining why the app needs them to improve permission grant rates

Testing Strategy

  • Development Testing: Test on both physical Android and iOS devices, as emulators may have limited push notification support
  • Permission Flows: Test scenarios where users deny permissions and verify the app handles this gracefully
  • Token Persistence: Verify tokens persist across app restarts and are correctly updated on refresh
  • Logging: Enable verbose logging during development to track token generation and registration