From 7253ef08d06a5a732883e541db615e0a23d33feb Mon Sep 17 00:00:00 2001 From: Keyur Gandhi Date: Mon, 16 Feb 2026 22:39:30 +0530 Subject: [PATCH 1/3] fix(android): move TcSdk.init() to background thread to prevent ANR TcSdk.init() performs synchronous ContentResolver.query() to the Truecaller app's ContentProvider, which can block for seconds when the Truecaller app is slow to respond. Since Flutter MethodChannels dispatch to the Android main thread, this blocks the UI thread and triggers ANR. This change wraps TcSdk.init() in a single-thread executor so the ContentProvider queries run on a background thread. The result is posted back to the main thread via Handler for the MethodChannel response. Co-Authored-By: Claude Opus 4.6 --- .../com/truecallersdk/TruecallerSdkPlugin.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt index 072de4a..0323d77 100644 --- a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt +++ b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt @@ -58,6 +58,8 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors const val TAG = "TruecallerSdkPlugin" const val INITIALIZE_SDK = "initializeSDK" @@ -91,6 +93,8 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne private var binding: ActivityPluginBinding? = null private var launcher: ActivityResultLauncher? = null private val gson = Gson() + private val ioExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(flutterPluginBinding.binaryMessenger) @@ -106,7 +110,21 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { INITIALIZE_SDK -> { - getTcSdkOptions(call)?.let { TcSdk.init(it) } ?: result.error( + getTcSdkOptions(call)?.let { options -> + // Run TcSdk.init() on a background thread to avoid ANR. + // During init, ContentResolver.query() to the Truecaller app's + // ContentProvider can block for seconds. Running on a background + // thread prevents main-thread ANR. + ioExecutor.execute { + try { + TcSdk.init(options) + mainHandler.post { result.success(true) } + } catch (e: Exception) { + android.util.Log.e(TAG, "TcSdk.init failed", e) + mainHandler.post { result.success(false) } + } + } + } ?: result.error( "UNAVAILABLE", "Activity not available.", null @@ -468,6 +486,7 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne } private fun cleanUp() { + ioExecutor.shutdownNow() TcSdk.clear() launcher = null binding?.removeActivityResultListener(this) From 96800a41ef42ad8d63fd2d9d82973ce928318a3a Mon Sep 17 00:00:00 2001 From: Keyur Gandhi Date: Tue, 17 Feb 2026 09:23:22 +0530 Subject: [PATCH 2/3] =?UTF-8?q?fix(android):=20harden=20background=20init?= =?UTF-8?q?=20=E2=80=94=20guard=20stale=20result,=20graceful=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AtomicBoolean guard to prevent result.success() after cleanUp() detaches the MethodChannel (fixes IllegalStateException on stale reply) - Use shutdown() instead of shutdownNow() so in-flight TcSdk.init() completes gracefully instead of being interrupted mid-execution - Recreate executor in onAttachedToActivity() after prior shutdown - Per-call resultConsumed AtomicBoolean prevents double-reply Co-Authored-By: Claude Opus 4.6 --- .../com/truecallersdk/TruecallerSdkPlugin.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt index 0323d77..b6e6f2c 100644 --- a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt +++ b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt @@ -60,6 +60,7 @@ import io.flutter.plugin.common.PluginRegistry import java.util.Locale import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean const val TAG = "TruecallerSdkPlugin" const val INITIALIZE_SDK = "initializeSDK" @@ -93,8 +94,11 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne private var binding: ActivityPluginBinding? = null private var launcher: ActivityResultLauncher? = null private val gson = Gson() - private val ioExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private var ioExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + // Guards against posting result.success() after cleanUp() has detached the MethodChannel. + // Set to true when cleanUp() runs; checked before posting result back to main thread. + private val isCleanedUp = AtomicBoolean(false) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(flutterPluginBinding.binaryMessenger) @@ -115,13 +119,22 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne // During init, ContentResolver.query() to the Truecaller app's // ContentProvider can block for seconds. Running on a background // thread prevents main-thread ANR. + val resultConsumed = AtomicBoolean(false) ioExecutor.execute { try { TcSdk.init(options) - mainHandler.post { result.success(true) } + mainHandler.post { + if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { + result.success(true) + } + } } catch (e: Exception) { android.util.Log.e(TAG, "TcSdk.init failed", e) - mainHandler.post { result.success(false) } + mainHandler.post { + if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { + result.success(false) + } + } } } } ?: result.error( @@ -447,6 +460,11 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne } override fun onAttachedToActivity(binding: ActivityPluginBinding) { + isCleanedUp.set(false) + // Recreate executor if it was shut down during a previous cleanUp() + if (ioExecutor.isShutdown) { + ioExecutor = Executors.newSingleThreadExecutor() + } if (binding.activity is FragmentActivity) { this.binding = binding this.activity = binding.activity as FragmentActivity @@ -486,7 +504,11 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne } private fun cleanUp() { - ioExecutor.shutdownNow() + isCleanedUp.set(true) + // Use shutdown() instead of shutdownNow() to let any in-flight TcSdk.init() + // finish gracefully. shutdownNow() interrupts the thread mid-execution which + // could leave the TcSdk singleton in a half-initialized state. + ioExecutor.shutdown() TcSdk.clear() launcher = null binding?.removeActivityResultListener(this) From ab8329de4c7e46f81ef55b14169c2148aae0a8f7 Mon Sep 17 00:00:00 2001 From: Keyur Gandhi Date: Tue, 17 Feb 2026 09:29:37 +0530 Subject: [PATCH 3/3] fix(android): catch RejectedExecutionException, order TcSdk.clear() after init - Wrap ioExecutor.execute in try-catch for RejectedExecutionException to prevent crash during rapid detach-reattach (config change) - Submit TcSdk.clear() to executor before shutdown() so it runs after any in-flight TcSdk.init() completes, preventing data race on the TcSdk singleton Co-Authored-By: Claude Opus 4.6 --- .../com/truecallersdk/TruecallerSdkPlugin.kt | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt index b6e6f2c..d3d7562 100644 --- a/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt +++ b/android/src/main/kotlin/com/truecallersdk/TruecallerSdkPlugin.kt @@ -120,22 +120,28 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne // ContentProvider can block for seconds. Running on a background // thread prevents main-thread ANR. val resultConsumed = AtomicBoolean(false) - ioExecutor.execute { - try { - TcSdk.init(options) - mainHandler.post { - if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { - result.success(true) + try { + ioExecutor.execute { + try { + TcSdk.init(options) + mainHandler.post { + if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { + result.success(true) + } } - } - } catch (e: Exception) { - android.util.Log.e(TAG, "TcSdk.init failed", e) - mainHandler.post { - if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { - result.success(false) + } catch (e: Exception) { + android.util.Log.e(TAG, "TcSdk.init failed", e) + mainHandler.post { + if (!isCleanedUp.get() && resultConsumed.compareAndSet(false, true)) { + result.success(false) + } } } } + } catch (e: java.util.concurrent.RejectedExecutionException) { + // Executor was shut down (rapid detach-reattach during config change) + android.util.Log.w(TAG, "Executor shut down, cannot init SDK", e) + result.success(false) } } ?: result.error( "UNAVAILABLE", @@ -505,11 +511,17 @@ public class TruecallerSdkPlugin : FlutterPlugin, MethodCallHandler, EventChanne private fun cleanUp() { isCleanedUp.set(true) - // Use shutdown() instead of shutdownNow() to let any in-flight TcSdk.init() - // finish gracefully. shutdownNow() interrupts the thread mid-execution which - // could leave the TcSdk singleton in a half-initialized state. + // Submit TcSdk.clear() to the executor so it runs after any in-flight + // TcSdk.init() completes — prevents clearing a half-initialized singleton. + // RejectedExecutionException means executor already shut down (no in-flight + // task), so we clear directly on the main thread as a fallback. + try { + ioExecutor.execute { TcSdk.clear() } + } catch (e: java.util.concurrent.RejectedExecutionException) { + TcSdk.clear() + } + // shutdown() lets the queued clear() task finish before the thread terminates. ioExecutor.shutdown() - TcSdk.clear() launcher = null binding?.removeActivityResultListener(this) binding = null