Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry.NewIntentListener
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

/** MindboxAndroidPlugin */
class MindboxAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, NewIntentListener {
private lateinit var context: Activity
private var binding: ActivityPluginBinding? = null
private var deviceUuidSubscription: String? = null
private var tokenSubscription: String? = null
private val deviceUuidSubscriptions = mutableListOf<String>()
private val tokenSubscriptions = mutableListOf<String>()
private lateinit var channel: MethodChannel

inner class InAppCallbackImpl : InAppCallback {
Expand Down Expand Up @@ -83,27 +85,76 @@ class MindboxAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Ne
}
}
"getDeviceUUID" -> {
if (deviceUuidSubscription != null) {
Mindbox.disposeDeviceUuidSubscription(deviceUuidSubscription!!)
val subscriptionRef = AtomicReference<String?>(null)
val isResultSent = AtomicBoolean(false)

val subscriptionId = Mindbox.subscribeDeviceUuid { uuid ->
if (isResultSent.compareAndSet(false, true)) {
result.success(uuid)

val id = subscriptionRef.get()
if (id != null) {
Mindbox.disposeDeviceUuidSubscription(id)
deviceUuidSubscriptions.remove(id)
}
}
}
deviceUuidSubscription = Mindbox.subscribeDeviceUuid { uuid ->
result.success(uuid)

subscriptionRef.set(subscriptionId)
deviceUuidSubscriptions.add(subscriptionId)

// If callback was synchronous, unsubscribe immediately
if (isResultSent.get()) {
Mindbox.disposeDeviceUuidSubscription(subscriptionId)
deviceUuidSubscriptions.remove(subscriptionId)
}
}
"getToken" -> {
if (tokenSubscription != null) {
Mindbox.disposePushTokenSubscription(tokenSubscription!!)
val subscriptionRef = AtomicReference<String?>(null)
val isResultSent = AtomicBoolean(false)

val subscriptionId = Mindbox.subscribePushToken { token ->
if (isResultSent.compareAndSet(false, true)) {
result.success(token)

val id = subscriptionRef.get()
if (id != null) {
Mindbox.disposePushTokenSubscription(id)
tokenSubscriptions.remove(id)
}
}
}
tokenSubscription = Mindbox.subscribePushToken { token ->
result.success(token)

subscriptionRef.set(subscriptionId)
tokenSubscriptions.add(subscriptionId)

if (isResultSent.get()) {
Mindbox.disposePushTokenSubscription(subscriptionId)
tokenSubscriptions.remove(subscriptionId)
}
}
"getTokens" -> {
if (tokenSubscription != null) {
Mindbox.disposePushTokenSubscription(tokenSubscription!!)
val subscriptionRef = AtomicReference<String?>(null)
val isResultSent = AtomicBoolean(false)

val subscriptionId = Mindbox.subscribePushTokens { token ->
if (isResultSent.compareAndSet(false, true)) {
result.success(token)

val id = subscriptionRef.get()
if (id != null) {
Mindbox.disposePushTokenSubscription(id)
tokenSubscriptions.remove(id)
}
}
}
tokenSubscription = Mindbox.subscribePushTokens { token ->
result.success(token)

subscriptionRef.set(subscriptionId)
tokenSubscriptions.add(subscriptionId)

if (isResultSent.get()) {
Mindbox.disposePushTokenSubscription(subscriptionId)
tokenSubscriptions.remove(subscriptionId)
}
}
"executeAsyncOperation" -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ class MindboxMethodHandler {
final pendingCallbackMethodsCopy =
List<_PendingCallbackMethod>.from(_pendingCallbackMethods);
for (final callbackMethod in pendingCallbackMethodsCopy) {
callbackMethod.callback(
await channel.invokeMethod(callbackMethod.methodName) ?? 'null');
channel
.invokeMethod(callbackMethod.methodName)
.then((result) {
callbackMethod.callback(result ?? 'null');
}).catchError((e) {
_logError('Error processing pending method ${callbackMethod.methodName}: $e');
});
Comment on lines +87 to +93
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The asynchronous callback processing creates a potential race condition where callbacks may execute after the init completes but before other state is fully initialized. Consider using unawaited() from dart:async to explicitly document that these futures are intentionally not awaited, which improves code clarity and helps prevent accidental regressions.

Copilot uses AI. Check for mistakes.
}
final pendingOperationsCopy =
List<_PendingOperations>.from(_pendingOperations);
Expand All @@ -99,6 +104,8 @@ class MindboxMethodHandler {
if (operation.errorCallback != null) {
final mindboxError = _convertPlatformExceptionToMindboxError(e);
operation.errorCallback!(mindboxError);
} else {
_logError('Error processing pending operation ${operation.methodName}: $e');
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mindbox_platform_interface/mindbox_platform_interface.dart';

Expand Down Expand Up @@ -405,6 +406,72 @@ void main() {
expect(() => completer.future, throwsA(isA<MindboxInternalError>()));
},
);

test(
'Verify that init completes even if pending getDeviceUUID hangs, allowing retries',
() async {
int getDeviceUUIDCallCount = 0;

// Mock handler that hangs on first getDeviceUUID
Future slowMockMethodCallHandler(MethodCall methodCall) async {
switch (methodCall.method) {
case 'init':
return Future.value(true);
case 'getDeviceUUID':
getDeviceUUIDCallCount++;
if (getDeviceUUIDCallCount == 1) {
// First call hangs (pending one)
return Completer<String>().future;
} else {
// Subsequent calls succeed
return Future.value('retry-uuid');
}
case 'writeNativeLog':
return Future.value(null);
default:
return 'dummy-response';
}
}

TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, slowMockMethodCallHandler);

// 1. Call getDeviceUUID before init. It goes to pending.
bool callback1Called = false;
handler.getDeviceUUID(callback: (uuid) {
callback1Called = true;
});

// 2. Call init.
final validConfig = Configuration(
domain: 'domain',
endpointIos: 'endpointIos',
endpointAndroid: 'endpointAndroid',
subscribeCustomerIfCreated: true,
);

// This should now complete even though the first getDeviceUUID is hanging
// Adding timeout to fail faster if regression occurs (hangs indefinitely)
await handler
.init(configuration: validConfig)
.timeout(const Duration(seconds: 5));

expect(getDeviceUUIDCallCount, equals(1),
reason: 'First call should have been triggered');
expect(callback1Called, isFalse,
reason: 'First callback is still hanging');

// 3. Call getDeviceUUID again (retry).
// This should succeed because init is complete.
final completer = Completer<String>();
handler.getDeviceUUID(callback: (uuid) => completer.complete(uuid));

final result = await completer.future.timeout(const Duration(seconds: 1));

expect(result, equals('retry-uuid'));
expect(getDeviceUUIDCallCount, equals(2));
},
);
}

class StubMindboxMethodHandler {
Expand Down