purchases-flutter icon indicating copy to clipboard operation
purchases-flutter copied to clipboard

Crash within first second of iOS app launch (restorePurchases?)

Open joachimbulow opened this issue 7 months ago • 8 comments

‼️ Required data ‼️

Do not remove any of the steps from the template below. If a step is not applicable to your issue, please leave that step empty.

There are a lot of things that can contribute to things not working. Having a very basic understanding of your environment will help us understand your issue faster!

Environment

  • [x] Output of flutter doctor Doctor summary (to see all details, run flutter doctor -v):

[✓] Flutter (Channel stable, 3.22.3, on macOS 15.4.1 24E263 darwin-arm64, locale en-DK) [✓] Android toolchain - develop for Android devices (Android SDK version 32.1.0-rc1) [✓] Xcode - develop for iOS and macOS (Xcode 16.3) [✓] Chrome - develop for the web [✓] Android Studio (version 2021.1) [✓] IntelliJ IDEA Ultimate Edition (version 2022.1.1) [✓] VS Code (version 1.99.2) [✓] Connected device (5 available) ! Error: Browsing on the local area network for Dev iphone. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac. The device must be opted into Developer Mode to connect wirelessly. (code -27) [✓] Network resources

  • [x] Version of purchases-flutter: purchases_flutter: ^8.7.4
  • [ ] Testing device version e.g.: iOS 15.5, Android API 30, etc.
  • [x] How often the issue occurs- every one of your customers is impacted? Only in dev? Going from Crashlytics reports about 200 times a day. So not a lot, per user. This is a rare crash.
  • [ ] Debug logs that reproduce the issue
  • [ ] Steps to reproduce, with a description of expected vs. actual behavior Other information (e.g. stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, etc.)

Describe the bug

A clear and concise description of what the bug is. The more detail you can provide the faster our team will be able to triage and resolve the issue.

One of our most thrown errors is a first-second crash on iOS, when looking in Crashlytics. It is related to this library. Below is the stack trace, which originates in main.swift.

          Crashed: com.apple.main-thread
0  libswiftCore.dylib             0x338c4 _assertionFailure(_:_:file:line:flags:) + 172
1  PurchasesHybridCommon          0xb038 specialized static CommonFunctionality.customerInfo(completion:) + 15 (FatalErrorUtil.swift:15)
2  PurchasesHybridCommon          0x7708 @objc static CommonFunctionality.restorePurchases(completion:) + 76
3  purchases_flutter              0x9f2c -[PurchasesFlutterPlugin getCustomerInfoWithResult:] + 348 (PurchasesFlutterPlugin.m:348)
4  purchases_flutter              0x893c -[PurchasesFlutterPlugin handleMethodCall:result:] + 101 (PurchasesFlutterPlugin.m:101)
5  Flutter                        0x5c74bc InternalFlutterGpu_Texture_AsImage + 6316
6  Flutter                        0x43d38 (Missing UUID 4c4c44dc55553144a1b8f18f0c0ee878)
7  libdispatch.dylib              0x1aac _dispatch_call_block_and_release + 32
8  libdispatch.dylib              0x1b584 _dispatch_client_callout + 16
9  libdispatch.dylib              0x38574 _dispatch_main_queue_drain.cold.5 + 812
10 libdispatch.dylib              0x10d30 _dispatch_main_queue_drain + 180
11 libdispatch.dylib              0x10c6c _dispatch_main_queue_callback_4CF + 44
12 CoreFoundation                 0x732b4 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 16
13 CoreFoundation                 0x710b0 __CFRunLoopRun + 1980
14 CoreFoundation                 0x95700 CFRunLoopRunSpecific + 572
15 GraphicsServices               0x1190 GSEventRunModal + 168
16 UIKitCore                      0x3ca240 -[UIApplication _run] + 816
17 UIKitCore                      0x3c8470 UIApplicationMain + 336
18 Runner                         0x8e4c main + 5 (AppDelegate.swift:5)
19 ???                            0x1ac8d7ad8 (Missing)

Additional context

Add any other context about the problem here.

Fyi the stack trace is static - seems to always be related to restorePurchases - i have seen other issues with stacktraces that look like this one, but not specifically with this stack trace.

I am unable to replicate it at the moment.

joachimbulow avatar May 02 '25 10:05 joachimbulow

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!

RCGitBot avatar May 02 '25 10:05 RCGitBot

@joachimbulow Thanks for reporting this. Can you share more about the methods you call when your app starts? Do you call syncPurchases/restorePurchases right away?

Jethro87 avatar May 06 '25 20:05 Jethro87

This is a complete crash of the application which can now be replicated.

Below is a singleton which we use to initialize the application.

Notice multiple calls to setUserId might initialize multiple times. I read people on forums did this - but i guess if you are not graceful in the handling of this that could be related to the issue.

At this point i am not sure what to do

import 'dart:io';

import 'package:doubble_app/common/util/debug_logger.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

/// Class utilizing Singleton design pattern. Get static instance by calling unnamed constructor.
/// Make sure it is initialized using named constructor.
class RevenueCat {
  bool _hasInitialized = false;

  RevenueCat._privateConstructor();

  static final RevenueCat _instance = RevenueCat._privateConstructor();

  // Always initialize using initialize contructor before using this Singleton
  factory RevenueCat() {
    return _instance;
  }

  /// Initialize the RevenueCat SDK with API keys
  static Future<RevenueCat> initialize() async {
    if (_instance._hasInitialized) {
      return _instance;
    }

    debugLog('RevenueCat.initialize()');

    await Purchases.setLogLevel(LogLevel.info);

    PurchasesConfiguration configuration;
    if (Platform.isAndroid) {
      configuration = PurchasesConfiguration(dotenv.get('REVENUECAT_PUBLIC_GOOGLE_API_KEY', fallback: 'key'));
    } else {
      // iOS
      configuration = PurchasesConfiguration(dotenv.get('REVENUECAT_PUBLIC_APPLE_API_KEY', fallback: 'key'));
    }

    await Future.delayed(const Duration(seconds: 1));

    await Purchases.configure(configuration);

    _instance._hasInitialized = true;

    return _instance;
  }

  /// Log in to RevenueCat with a user ID
  void setUserId(String userId) async {
    if (!_instance._hasInitialized) {
      await initialize();
    }

    debugLog('RevenueCat.logIn()');
    await Purchases.logIn(userId);
  }
}

Here is an x-code crash report as well - spooky stuff.

Image

Is this on you guys' radar? I am pretty scared we are bleeding in production @Jethro87

joachimbulow avatar May 13 '25 07:05 joachimbulow

@joachimbulow Thank you for the additional information. So to confirm, on app startup, you call initialize. Then, another method calls setUserId (possibly multiple times?), which itself potentially can call initialize. To clarify, do you call any other RevenueCat methods when you app starts up?

Also, instead of your hasInitialized boolean, you can use the isConfigured method - docs. This will return a boolean whether the SDK has previously been configured or not.

Jethro87 avatar May 15 '25 19:05 Jethro87

Yes - we may have called configure multiple times and logIn multiple times. This would probably consistently happen during out boot as it is one of the first things we do.

But, for full disclosure, we MAY also call a few other functions as described below:

/// React to auth token changes to ensure the entitlements are up-to-date
    ref.listen(authProvider, (previous, next) {
      if (previous?.valueOrNull?.accessTokenPayload != next.valueOrNull?.accessTokenPayload) {
        _syncDebouncer.run(() async {
          //// GET CUSTOMER INFO HERE 👇👇
          final customerInfo = await Purchases.getCustomerInfo();

          // This function just double checks entitlements are valid
          customerInfoUpdated(customerInfo);
        });
      }
    });

    final info = await Purchases.getCustomerInfo();

     ///// ADDS A LISTENER HERE 👇👇
    Purchases.addCustomerInfoUpdateListener(customerInfoUpdated);

    return info;

I cannot guarantee the order of this code's execution as its happening inside a listener running when access tokens change. So you may assume it runs before we call configure etc on our Revenuecat instance.


We may also call 👇

final Offerings offerings = await Purchases.getOfferings(); at any arbitrary time during boot.


And thats really all the calls we may make directly through the package during boot when the crashes happen.

All things being equal, we would never expect the library to be able to crash our application no matter what we call and when.

If this is a misunderstanding, and we should in fact make sure we are completely initialized, we should probably wrap all calls to Purchases and make sure we can only call methods when valid, and only configure once, etc.🤔

joachimbulow avatar May 16 '25 09:05 joachimbulow

So just to summarize, as this issue is becoming bloated with ugly code:

Here are the calls we can assume may be called in arbitrary order and an arbitrary amount of times:

Purchases.setLogLevel(LogLevel.info)
Purchases.logIn(userId)
Purchases.configure(configuration)
Purchases.getOfferings();
Purchases.getCustomerInfo();
Purchases.addCustomerInfoUpdateListener(customerInfoUpdated);

joachimbulow avatar May 16 '25 09:05 joachimbulow

@joachimbulow Thank you for the additional explanation and code samples. These crashes may be occurring due to other Purchases methods being called before configure. Is it possible for you to move the configure call into your main function? For example:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");

  // Initialize RevenueCat here
  await RevenueCat.initialize();

  runApp(MyApp());
}

Many customers end up wrapping Purchases calls and adding additional logic (for example, ensuring the SDK has been initialized), but if you move the initialize method into main.dart, you shouldn't have to take the effort to wrap everything.

Jethro87 avatar May 16 '25 20:05 Jethro87

Maybe you can fix your library to display an error level message instead of hard crashing? 🤔 Would be easier to debug, and be avoid the crashed ofc.

joachimbulow avatar May 28 '25 07:05 joachimbulow

Hi @joachimbulow I'm very sorry about the delay on this reply. We've been auditing our ticketing system and found that your ticket was never resolved. Are you still running into this issue? If so we can resume looking into this.

HaleyRevcat avatar Oct 14 '25 18:10 HaleyRevcat

Eh. I think it can still happen - as mentioned i think any third party SDK is responsible for not crashing apps no matter how they are used.

With that being said i rewrote the code a bit to more safe - and am not facing the issue anymore.

joachimbulow avatar Oct 15 '25 07:10 joachimbulow