[Android] Add initial batch command support (#32326)

diff --git a/.github/workflows/java-tests.yaml b/.github/workflows/java-tests.yaml
index f19d451..3aa6935 100644
--- a/.github/workflows/java-tests.yaml
+++ b/.github/workflows/java-tests.yaml
@@ -132,6 +132,17 @@
                      --tool-cluster "im" \
                      --tool-args "onnetwork-long-im-invoke --nodeid 1 --setup-pin-code 20202021 --discriminator 3840 -t 1000" \
                      --factoryreset \
+                  '                    
+            - name: Run IM Batch Invoke Test
+              run: |
+                  scripts/run_in_python_env.sh out/venv \
+                  './scripts/tests/run_java_test.py \
+                     --app out/linux-x64-all-clusters-ipv6only-no-ble-no-wifi-tsan-clang-test/chip-all-clusters-app \
+                     --app-args "--discriminator 3840 --interface-id -1" \
+                     --tool-path out/linux-x64-java-matter-controller \
+                     --tool-cluster "im" \
+                     --tool-args "onnetwork-long-im-batch-invoke --nodeid 1 --setup-pin-code 20202021 --discriminator 3840 -t 1000" \
+                     --factoryreset \
                   '
             - name: Run IM Read Test
               run: |
diff --git a/examples/java-matter-controller/BUILD.gn b/examples/java-matter-controller/BUILD.gn
index 34de1ed..76acd19 100644
--- a/examples/java-matter-controller/BUILD.gn
+++ b/examples/java-matter-controller/BUILD.gn
@@ -57,6 +57,7 @@
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkFabricCommand.kt",
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkInstanceNameCommand.kt",
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkLongCommand.kt",
+    "java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImExtendableInvokeCommand.kt",
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImInvokeCommand.kt",
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImReadCommand.kt",
     "java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImSubscribeCommand.kt",
diff --git a/examples/java-matter-controller/java/src/com/matter/controller/Main.kt b/examples/java-matter-controller/java/src/com/matter/controller/Main.kt
index d0e2ef8..a1a66a8 100644
--- a/examples/java-matter-controller/java/src/com/matter/controller/Main.kt
+++ b/examples/java-matter-controller/java/src/com/matter/controller/Main.kt
@@ -67,6 +67,7 @@
     PairOnNetworkLongImSubscribeCommand(controller, credentialsIssuer),
     PairOnNetworkLongImWriteCommand(controller, credentialsIssuer),
     PairOnNetworkLongImInvokeCommand(controller, credentialsIssuer),
+    PairOnNetworkLongImExtendableInvokeCommand(controller, credentialsIssuer),
   )
 }
 
diff --git a/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImExtendableInvokeCommand.kt b/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImExtendableInvokeCommand.kt
new file mode 100644
index 0000000..d80e71a
--- /dev/null
+++ b/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImExtendableInvokeCommand.kt
@@ -0,0 +1,183 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package com.matter.controller.commands.pairing
+
+import chip.devicecontroller.ChipDeviceController
+import chip.devicecontroller.ExtendableInvokeCallback
+import chip.devicecontroller.GetConnectedDeviceCallbackJni.GetConnectedDeviceCallback
+import chip.devicecontroller.model.InvokeElement
+import chip.devicecontroller.model.InvokeResponseData
+import chip.devicecontroller.model.NoInvokeResponseData
+import chip.devicecontroller.model.Status
+import com.matter.controller.commands.common.CredentialsIssuer
+import java.util.logging.Level
+import java.util.logging.Logger
+import kotlin.UShort
+import matter.tlv.AnonymousTag
+import matter.tlv.ContextSpecificTag
+import matter.tlv.TlvWriter
+
+class PairOnNetworkLongImExtendableInvokeCommand(
+  controller: ChipDeviceController,
+  credsIssue: CredentialsIssuer?
+) :
+  PairingCommand(
+    controller,
+    "onnetwork-long-im-batch-invoke",
+    credsIssue,
+    PairingModeType.ON_NETWORK,
+    PairingNetworkType.NONE,
+    DiscoveryFilterType.LONG_DISCRIMINATOR
+  ) {
+  private var devicePointer: Long = 0
+
+  private fun setDevicePointer(devicePointer: Long) {
+    this.devicePointer = devicePointer
+  }
+
+  private inner class InternalInvokeCallback : ExtendableInvokeCallback {
+    private var responseCount = 0
+
+    override fun onError(e: Exception) {
+      logger.log(Level.INFO, "Batch Invoke receive onError" + e.message)
+      setFailure("invoke failure")
+    }
+
+    override fun onResponse(invokeResponseData: InvokeResponseData) {
+      logger.log(Level.INFO, "Batch Invoke receive OnResponse on $invokeResponseData")
+      val clusterId = invokeResponseData.getClusterId().getId()
+      val commandId = invokeResponseData.getCommandId().getId()
+      val tlvData = invokeResponseData.getTlvByteArray()
+      val jsonData = invokeResponseData.getJsonString()
+      val status = invokeResponseData.getStatus()
+
+      if (clusterId == CLUSTER_ID_IDENTIFY && commandId == IDENTIFY_COMMAND) {
+        if (tlvData != null || jsonData != null) {
+          setFailure("invoke failure with problematic payload")
+        }
+        if (
+          status != null && status.status != Status.Code.Success && status.clusterStatus.isPresent()
+        ) {
+          setFailure("invoke failure with incorrect status")
+        }
+      }
+
+      if (clusterId == CLUSTER_ID_TEST && commandId == TEST_ADD_ARGUMENT_RSP_COMMAND) {
+        if (tlvData == null || jsonData == null) {
+          setFailure("invoke failure with problematic payload")
+        }
+
+        if (!jsonData.equals("""{"0:UINT":2}""")) {
+          setFailure("invoke failure with problematic json")
+        }
+
+        if (status != null) {
+          setFailure("invoke failure with incorrect status")
+        }
+      }
+      responseCount++
+    }
+
+    override fun onNoResponse(noInvokeResponseData: NoInvokeResponseData) {
+      logger.log(Level.INFO, "Batch Invoke receive onNoResponse on $noInvokeResponseData")
+    }
+
+    override fun onDone() {
+      if (responseCount == TEST_COMMONDS_NUM) {
+        setSuccess()
+      } else {
+        setFailure("invoke failure")
+      }
+    }
+  }
+
+  private inner class InternalGetConnectedDeviceCallback : GetConnectedDeviceCallback {
+    override fun onDeviceConnected(devicePointer: Long) {
+      setDevicePointer(devicePointer)
+      logger.log(Level.INFO, "onDeviceConnected")
+    }
+
+    override fun onConnectionFailure(nodeId: Long, error: Exception) {
+      logger.log(Level.INFO, "onConnectionFailure")
+    }
+  }
+
+  override fun runCommand() {
+    val number: UShort = 1u
+    val tlvWriter1 = TlvWriter()
+    tlvWriter1.startStructure(AnonymousTag)
+    tlvWriter1.put(ContextSpecificTag(0), number)
+    tlvWriter1.endStructure()
+
+    val element1: InvokeElement =
+      InvokeElement.newInstance(
+        /* endpointId= */ 0,
+        CLUSTER_ID_IDENTIFY,
+        IDENTIFY_COMMAND,
+        tlvWriter1.getEncoded(),
+        null
+      )
+
+    val tlvWriter2 = TlvWriter()
+    tlvWriter2.startStructure(AnonymousTag)
+    tlvWriter2.put(ContextSpecificTag(0), number)
+    tlvWriter2.put(ContextSpecificTag(1), number)
+    tlvWriter2.endStructure()
+
+    val element2: InvokeElement =
+      InvokeElement.newInstance(
+        /* endpointId= */ 1,
+        CLUSTER_ID_TEST,
+        TEST_ADD_ARGUMENT_COMMAND,
+        tlvWriter2.getEncoded(),
+        null
+      )
+
+    val invokeList = listOf(element1, element2)
+    currentCommissioner()
+      .pairDeviceWithAddress(
+        getNodeId(),
+        getRemoteAddr().address.hostAddress,
+        MATTER_PORT,
+        getDiscriminator(),
+        getSetupPINCode(),
+        null
+      )
+    currentCommissioner().setCompletionListener(this)
+    waitCompleteMs(getTimeoutMillis())
+    currentCommissioner()
+      .getConnectedDevicePointer(getNodeId(), InternalGetConnectedDeviceCallback())
+    clear()
+    currentCommissioner()
+      .extendableInvoke(InternalInvokeCallback(), devicePointer, invokeList, 0, 0)
+    waitCompleteMs(getTimeoutMillis())
+  }
+
+  companion object {
+    private val logger =
+      Logger.getLogger(PairOnNetworkLongImExtendableInvokeCommand::class.java.name)
+
+    private const val MATTER_PORT = 5540
+    private const val CLUSTER_ID_IDENTIFY = 0x0003L
+    private const val IDENTIFY_COMMAND = 0L
+    private const val CLUSTER_ID_TEST = 0xFFF1FC05L
+    private const val TEST_ADD_ARGUMENT_COMMAND = 0X04L
+    private const val TEST_ADD_ARGUMENT_RSP_COMMAND = 0X01L
+    private const val TEST_COMMONDS_NUM = 2
+  }
+}
diff --git a/kotlin-detect-config.yaml b/kotlin-detect-config.yaml
index 2ad54a0..c839829 100644
--- a/kotlin-detect-config.yaml
+++ b/kotlin-detect-config.yaml
@@ -104,6 +104,7 @@
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkInstanceNameCommand.kt"
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongCommand.kt"
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImInvokeCommand.kt"
+            - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImExtendableInvokeCommand.kt"
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkLongImWriteCommand.kt"
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkShortCommand.kt"
             - "**/examples/java-matter-controller/java/src/com/matter/controller/commands/pairing/PairOnNetworkVendorCommand.kt"
diff --git a/scripts/tests/java/im_test.py b/scripts/tests/java/im_test.py
index d8acc63..6ac3936 100755
--- a/scripts/tests/java/im_test.py
+++ b/scripts/tests/java/im_test.py
@@ -71,6 +71,14 @@
         DumpProgramOutputToQueue(self.thread_list, Fore.GREEN + "JAVA " + Style.RESET_ALL, java_process, self.queue)
         return java_process.wait()
 
+    def TestCmdOnnetworkLongImExtendableInvoke(self, nodeid, setuppin, discriminator, timeout):
+        java_command = self.command + ['im', 'onnetwork-long-im-batch-invoke', nodeid, setuppin, discriminator, timeout]
+        logging.info(f"Execute: {java_command}")
+        java_process = subprocess.Popen(
+            java_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        DumpProgramOutputToQueue(self.thread_list, Fore.GREEN + "JAVA " + Style.RESET_ALL, java_process, self.queue)
+        return java_process.wait()
+
     def TestCmdOnnetworkLongImWrite(self, nodeid, setuppin, discriminator, timeout):
         java_command = self.command + ['im', 'onnetwork-long-im-write', nodeid, setuppin, discriminator, timeout]
         logging.info(f"Execute: {java_command}")
@@ -101,6 +109,11 @@
             code = self.TestCmdOnnetworkLongImInvoke(self.nodeid, self.setup_pin_code, self.discriminator, self.timeout)
             if code != 0:
                 raise Exception(f"Testing pairing onnetwork-long-im-invoke failed with error {code}")
+        elif self.command_name == 'onnetwork-long-im-batch-invoke':
+            logging.info("Testing pairing onnetwork-long-im-batch-invoke")
+            code = self.TestCmdOnnetworkLongImExtendableInvoke(self.nodeid, self.setup_pin_code, self.discriminator, self.timeout)
+            if code != 0:
+                raise Exception(f"Testing pairing onnetwork-long-im-batch-invoke failed with error {code}")
         elif self.command_name == 'onnetwork-long-im-write':
             logging.info("Testing pairing onnetwork-long-im-write")
             code = self.TestCmdOnnetworkLongImWrite(self.nodeid, self.setup_pin_code, self.discriminator, self.timeout)
diff --git a/src/controller/java/AndroidCallbacks-JNI.cpp b/src/controller/java/AndroidCallbacks-JNI.cpp
index c87b669..fff1226 100644
--- a/src/controller/java/AndroidCallbacks-JNI.cpp
+++ b/src/controller/java/AndroidCallbacks-JNI.cpp
@@ -69,3 +69,14 @@
 {
     deleteInvokeCallback(env, self, callbackHandle);
 }
+
+JNI_METHOD(jlong, ExtendableInvokeCallbackJni, newCallback)
+(JNIEnv * env, jobject self)
+{
+    return newExtendableInvokeCallback(env, self);
+}
+
+JNI_METHOD(void, ExtendableInvokeCallbackJni, deleteCallback)(JNIEnv * env, jobject self, jlong callbackHandle)
+{
+    deleteExtendableInvokeCallback(env, self, callbackHandle);
+}
diff --git a/src/controller/java/AndroidCallbacks.cpp b/src/controller/java/AndroidCallbacks.cpp
index fb79752..fc9fbe0 100644
--- a/src/controller/java/AndroidCallbacks.cpp
+++ b/src/controller/java/AndroidCallbacks.cpp
@@ -791,6 +791,7 @@
     if (mCommandSender != nullptr)
     {
         Platform::Delete(mCommandSender);
+        mCommandSender = nullptr;
     }
 }
 
@@ -802,7 +803,6 @@
     VerifyOrReturn(env != nullptr, ChipLogError(Controller, "Could not get JNIEnv for current thread"));
     jmethodID onResponseMethod;
     JniLocalReferenceScope scope(env);
-    VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Unable to create Java InvokeElement: %s", ErrorStr(err)));
     VerifyOrReturn(mWrapperCallbackRef.HasValidObjectRef(),
                    ChipLogError(Controller, "mWrapperCallbackRef is not valid in %s", __func__));
     jobject wrapperCallbackRef = mWrapperCallbackRef.ObjectRef();
@@ -1023,6 +1023,170 @@
     }
 }
 
+ExtendableInvokeCallback::ExtendableInvokeCallback(jobject wrapperCallback)
+{
+    VerifyOrReturn(mWrapperCallbackRef.Init(wrapperCallback) == CHIP_NO_ERROR,
+                   ChipLogError(Controller, "Could not init mWrapperCallbackRef for ExtendableInvokeCallback"));
+}
+
+ExtendableInvokeCallback::~ExtendableInvokeCallback()
+{
+    if (mCommandSender != nullptr)
+    {
+        Platform::Delete(mCommandSender);
+        mCommandSender = nullptr;
+    }
+}
+
+void ExtendableInvokeCallback::OnResponse(app::CommandSender * apCommandSender,
+                                          const app::CommandSender::ResponseData & aResponseData)
+{
+    CHIP_ERROR err = CHIP_NO_ERROR;
+    JNIEnv * env   = JniReferences::GetInstance().GetEnvForCurrentThread();
+    VerifyOrReturn(env != nullptr, ChipLogError(Controller, "Could not get JNIEnv for current thread"));
+    jmethodID onResponseMethod;
+    JniLocalReferenceScope scope(env);
+    VerifyOrReturn(mWrapperCallbackRef.HasValidObjectRef(),
+                   ChipLogError(Controller, "mWrapperCallbackRef is not valid in %s", __func__));
+    jobject wrapperCallbackRef = mWrapperCallbackRef.ObjectRef();
+    DeviceLayer::StackUnlock unlock;
+
+    jobject jCommandRef = nullptr;
+    if (aResponseData.commandRef.HasValue())
+    {
+        err = JniReferences::GetInstance().CreateBoxedObject<jint>(
+            "java/lang/Integer", "(I)V", static_cast<jint>(aResponseData.commandRef.Value()), jCommandRef);
+        VerifyOrReturn(err == CHIP_NO_ERROR,
+                       ChipLogError(Controller, "Could not CreateBoxedObject with error %" CHIP_ERROR_FORMAT, err.Format()));
+    }
+
+    if (aResponseData.data != nullptr)
+    {
+        TLV::TLVReader readerForJavaTLV;
+        TLV::TLVReader readerForJson;
+        readerForJavaTLV.Init(*(aResponseData.data));
+
+        // Create TLV byte array to pass to Java layer
+        size_t bufferLen                  = readerForJavaTLV.GetRemainingLength() + readerForJavaTLV.GetLengthRead();
+        std::unique_ptr<uint8_t[]> buffer = std::unique_ptr<uint8_t[]>(new uint8_t[bufferLen]);
+        uint32_t size                     = 0;
+
+        TLV::TLVWriter writer;
+        writer.Init(buffer.get(), bufferLen);
+        err = writer.CopyElement(TLV::AnonymousTag(), readerForJavaTLV);
+        VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Failed CopyElement: %" CHIP_ERROR_FORMAT, err.Format()));
+        size = writer.GetLengthWritten();
+
+        chip::ByteArray jniByteArray(env, reinterpret_cast<jbyte *>(buffer.get()), static_cast<jint>(size));
+
+        // Convert TLV to JSON
+        std::string json;
+        readerForJson.Init(buffer.get(), size);
+        err = readerForJson.Next();
+        VerifyOrReturn(err == CHIP_NO_ERROR,
+                       ChipLogError(Controller, "Failed readerForJson next: %" CHIP_ERROR_FORMAT, err.Format()));
+        err = TlvToJson(readerForJson, json);
+        VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Failed TlvToJson: %" CHIP_ERROR_FORMAT, err.Format()));
+        UtfString jsonString(env, json.c_str());
+
+        err = JniReferences::GetInstance().FindMethod(env, wrapperCallbackRef, "onResponse",
+                                                      "(IJJLjava/lang/Integer;[BLjava/lang/String;)V", &onResponseMethod);
+        VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Unable to find onResponse method: %s", ErrorStr(err)));
+
+        env->CallVoidMethod(wrapperCallbackRef, onResponseMethod, static_cast<jint>(aResponseData.path.mEndpointId),
+                            static_cast<jlong>(aResponseData.path.mClusterId), static_cast<jlong>(aResponseData.path.mCommandId),
+                            jCommandRef, jniByteArray.jniValue(), jsonString.jniValue());
+    }
+    else
+    {
+        err = JniReferences::GetInstance().FindMethod(env, wrapperCallbackRef, "onResponse",
+                                                      "(IJJLjava/lang/Integer;ILjava/lang/Integer;)V", &onResponseMethod);
+        VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Unable to find onResponse method: %s", ErrorStr(err)));
+
+        jobject jClusterState = nullptr;
+        if (aResponseData.statusIB.mClusterStatus.HasValue())
+        {
+            err = JniReferences::GetInstance().CreateBoxedObject<jint>(
+                "java/lang/Integer", "(I)V", static_cast<jint>(aResponseData.statusIB.mClusterStatus.Value()), jClusterState);
+            VerifyOrReturn(err == CHIP_NO_ERROR,
+                           ChipLogError(Controller, "Could not CreateBoxedObject with error %" CHIP_ERROR_FORMAT, err.Format()));
+        }
+
+        env->CallVoidMethod(wrapperCallbackRef, onResponseMethod, static_cast<jint>(aResponseData.path.mEndpointId),
+                            static_cast<jlong>(aResponseData.path.mClusterId), static_cast<jlong>(aResponseData.path.mCommandId),
+                            jCommandRef, aResponseData.statusIB.mStatus, jClusterState);
+    }
+
+    VerifyOrReturn(!env->ExceptionCheck(), env->ExceptionDescribe());
+}
+
+void ExtendableInvokeCallback::OnNoResponse(app::CommandSender * commandSender,
+                                            const app::CommandSender::NoResponseData & aNoResponseData)
+{
+    CHIP_ERROR err = CHIP_NO_ERROR;
+    JNIEnv * env   = JniReferences::GetInstance().GetEnvForCurrentThread();
+    VerifyOrReturn(env != nullptr, ChipLogError(Controller, "Could not get JNIEnv for current thread"));
+    jmethodID onNoResponseMethod;
+    JniLocalReferenceScope scope(env);
+    VerifyOrReturn(mWrapperCallbackRef.HasValidObjectRef(),
+                   ChipLogError(Controller, "mWrapperCallbackRef is not valid in %s", __func__));
+    jobject wrapperCallbackRef = mWrapperCallbackRef.ObjectRef();
+    DeviceLayer::StackUnlock unlock;
+
+    err = JniReferences::GetInstance().FindMethod(env, wrapperCallbackRef, "onNoResponse", "(I)V", &onNoResponseMethod);
+    VerifyOrReturn(err == CHIP_NO_ERROR,
+                   ChipLogError(Controller, "Unable to find onNoResponse method: %" CHIP_ERROR_FORMAT, err.Format()));
+    env->CallVoidMethod(wrapperCallbackRef, onNoResponseMethod, static_cast<jint>(aNoResponseData.commandRef));
+    VerifyOrReturn(!env->ExceptionCheck(), env->ExceptionDescribe());
+}
+
+void ExtendableInvokeCallback::OnError(const app::CommandSender * apCommandSender, const app::CommandSender::ErrorData & aErrorData)
+{
+    CHIP_ERROR err = CHIP_NO_ERROR;
+    JNIEnv * env   = JniReferences::GetInstance().GetEnvForCurrentThread();
+    VerifyOrReturn(env != nullptr, ChipLogError(Controller, "Could not get JNIEnv for current thread"));
+    JniLocalReferenceScope scope(env);
+    ChipLogError(Controller, "ExtendableInvokeCallback::OnError is called with %u", aErrorData.error.AsInteger());
+    jthrowable exception;
+    err = AndroidControllerExceptions::GetInstance().CreateAndroidControllerException(env, ErrorStr(aErrorData.error),
+                                                                                      aErrorData.error.AsInteger(), exception);
+    VerifyOrReturn(
+        err == CHIP_NO_ERROR,
+        ChipLogError(Controller, "Unable to create AndroidControllerException with error: %" CHIP_ERROR_FORMAT, err.Format()));
+
+    jmethodID onErrorMethod;
+    VerifyOrReturn(mWrapperCallbackRef.HasValidObjectRef(),
+                   ChipLogError(Controller, "mWrapperCallbackRef is not valid in %s", __func__));
+    jobject wrapperCallback = mWrapperCallbackRef.ObjectRef();
+    err = JniReferences::GetInstance().FindMethod(env, wrapperCallback, "onError", "(Ljava/lang/Exception;)V", &onErrorMethod);
+    VerifyOrReturn(err == CHIP_NO_ERROR,
+                   ChipLogError(Controller, "Unable to find onError method:  %" CHIP_ERROR_FORMAT, err.Format()));
+
+    DeviceLayer::StackUnlock unlock;
+    env->CallVoidMethod(wrapperCallback, onErrorMethod, exception);
+    VerifyOrReturn(!env->ExceptionCheck(), env->ExceptionDescribe());
+}
+
+void ExtendableInvokeCallback::OnDone(app::CommandSender * apCommandSender)
+{
+    CHIP_ERROR err = CHIP_NO_ERROR;
+    JNIEnv * env   = JniReferences::GetInstance().GetEnvForCurrentThread();
+    VerifyOrReturn(env != nullptr, ChipLogError(Controller, "Could not get JNIEnv for current thread"));
+    JniLocalReferenceScope scope(env);
+    jmethodID onDoneMethod;
+    VerifyOrReturn(mWrapperCallbackRef.HasValidObjectRef(),
+                   ChipLogError(Controller, "mWrapperCallbackRef is not valid in %s", __func__));
+    jobject wrapperCallback = mWrapperCallbackRef.ObjectRef();
+    JniGlobalReference globalRef(std::move(mWrapperCallbackRef));
+
+    err = JniReferences::GetInstance().FindMethod(env, wrapperCallback, "onDone", "()V", &onDoneMethod);
+    VerifyOrReturn(err == CHIP_NO_ERROR, ChipLogError(Controller, "Could not find onDone method"));
+
+    DeviceLayer::StackUnlock unlock;
+    env->CallVoidMethod(wrapperCallback, onDoneMethod);
+    VerifyOrReturn(!env->ExceptionCheck(), env->ExceptionDescribe());
+}
+
 jlong newConnectedDeviceCallback(JNIEnv * env, jobject self, jobject callback)
 {
     chip::DeviceLayer::StackLock lock;
@@ -1085,5 +1249,19 @@
     chip::Platform::Delete(invokeCallback);
 }
 
+jlong newExtendableInvokeCallback(JNIEnv * env, jobject self)
+{
+    chip::DeviceLayer::StackLock lock;
+    ExtendableInvokeCallback * invokeCallback = chip::Platform::New<ExtendableInvokeCallback>(self);
+    return reinterpret_cast<jlong>(invokeCallback);
+}
+
+void deleteExtendableInvokeCallback(JNIEnv * env, jobject self, jlong callbackHandle)
+{
+    chip::DeviceLayer::StackLock lock;
+    ExtendableInvokeCallback * invokeCallback = reinterpret_cast<ExtendableInvokeCallback *>(callbackHandle);
+    VerifyOrReturn(invokeCallback != nullptr, ChipLogError(Controller, "ExtendableInvokeCallback handle is nullptr"));
+    chip::Platform::Delete(invokeCallback);
+}
 } // namespace Controller
 } // namespace chip
diff --git a/src/controller/java/AndroidCallbacks.h b/src/controller/java/AndroidCallbacks.h
index 700a1fb..0c49daf 100644
--- a/src/controller/java/AndroidCallbacks.h
+++ b/src/controller/java/AndroidCallbacks.h
@@ -25,6 +25,7 @@
 #include <lib/core/CHIPError.h>
 #include <lib/support/JniTypeWrappers.h>
 #include <list>
+#include <unordered_map>
 #include <utility>
 
 namespace chip {
@@ -138,6 +139,20 @@
     JniGlobalReference mWrapperCallbackRef;
 };
 
+struct ExtendableInvokeCallback : public app::CommandSender::ExtendableCallback
+{
+    ExtendableInvokeCallback(jobject wrapperCallback);
+    ~ExtendableInvokeCallback();
+
+    void OnResponse(app::CommandSender * commandSender, const app::CommandSender::ResponseData & aResponseData) override;
+    void OnNoResponse(app::CommandSender * commandSender, const app::CommandSender::NoResponseData & aNoResponseData) override;
+    void OnError(const app::CommandSender * apCommandSender, const app::CommandSender::ErrorData & aErrorData) override;
+    void OnDone(app::CommandSender * apCommandSender) override;
+
+    app::CommandSender * mCommandSender = nullptr;
+    JniGlobalReference mWrapperCallbackRef;
+};
+
 jlong newConnectedDeviceCallback(JNIEnv * env, jobject self, jobject callback);
 void deleteConnectedDeviceCallback(JNIEnv * env, jobject self, jlong callbackHandle);
 jlong newReportCallback(JNIEnv * env, jobject self, jobject subscriptionEstablishedCallbackJava,
@@ -147,6 +162,8 @@
 void deleteWriteAttributesCallback(JNIEnv * env, jobject self, jlong callbackHandle);
 jlong newInvokeCallback(JNIEnv * env, jobject self);
 void deleteInvokeCallback(JNIEnv * env, jobject self, jlong callbackHandle);
+jlong newExtendableInvokeCallback(JNIEnv * env, jobject self);
+void deleteExtendableInvokeCallback(JNIEnv * env, jobject self, jlong callbackHandle);
 
 } // namespace Controller
 } // namespace chip
diff --git a/src/controller/java/AndroidInteractionClient.cpp b/src/controller/java/AndroidInteractionClient.cpp
index a0b22bf..ec80957 100644
--- a/src/controller/java/AndroidInteractionClient.cpp
+++ b/src/controller/java/AndroidInteractionClient.cpp
@@ -431,7 +431,7 @@
 
 CHIP_ERROR PutPreencodedInvokeRequest(app::CommandSender & commandSender, app::CommandPathParams & path, const ByteSpan & data)
 {
-    // PrepareCommand does nott create the struct container with kFields and copycontainer below sets the
+    // PrepareCommand does not create the struct container with kFields and copycontainer below sets the
     // kFields container already
     ReturnErrorOnFailure(commandSender.PrepareCommand(path, false /* aStartDataStruct */));
     TLV::TLVWriter * writer = commandSender.GetCommandDataIBTLVWriter();
@@ -442,6 +442,191 @@
     return writer->CopyContainer(TLV::ContextTag(app::CommandDataIB::Tag::kFields), reader);
 }
 
+CHIP_ERROR PutPreencodedInvokeRequest(app::CommandSender & commandSender, app::CommandPathParams & path, const ByteSpan & data,
+                                      app::CommandSender::PrepareCommandParameters & prepareCommandParams)
+{
+    // PrepareCommand does not create the struct container with kFields and copycontainer below sets the
+    // kFields container already
+    ReturnErrorOnFailure(commandSender.PrepareCommand(path, prepareCommandParams));
+    TLV::TLVWriter * writer = commandSender.GetCommandDataIBTLVWriter();
+    VerifyOrReturnError(writer != nullptr, CHIP_ERROR_INCORRECT_STATE);
+    TLV::TLVReader reader;
+    reader.Init(data);
+    ReturnErrorOnFailure(reader.Next());
+    return writer->CopyContainer(TLV::ContextTag(app::CommandDataIB::Tag::kFields), reader);
+}
+
+CHIP_ERROR extendableInvoke(JNIEnv * env, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElementList,
+                            jint timedRequestTimeoutMs, jint imTimeoutMs)
+{
+    chip::DeviceLayer::StackLock lock;
+    CHIP_ERROR err                          = CHIP_NO_ERROR;
+    auto callback                           = reinterpret_cast<ExtendableInvokeCallback *>(callbackHandle);
+    app::CommandSender * commandSender      = nullptr;
+    uint16_t groupId                        = 0;
+    bool isEndpointIdValid                  = false;
+    bool isGroupIdValid                     = false;
+    jint listSize                           = 0;
+    uint16_t convertedTimedRequestTimeoutMs = static_cast<uint16_t>(timedRequestTimeoutMs);
+    app::CommandSender::ConfigParameters config;
+
+    ChipLogDetail(Controller, "IM extendableInvoke() called");
+
+    DeviceProxy * device = reinterpret_cast<DeviceProxy *>(devicePtr);
+    VerifyOrExit(device != nullptr, err = CHIP_ERROR_INCORRECT_STATE);
+    VerifyOrExit(device->GetSecureSession().HasValue(), err = CHIP_ERROR_MISSING_SECURE_SESSION);
+
+    VerifyOrExit(invokeElementList != nullptr, err = CHIP_ERROR_INVALID_ARGUMENT);
+    SuccessOrExit(err = JniReferences::GetInstance().GetListSize(invokeElementList, listSize));
+
+    if ((listSize > 1) && (device->GetSecureSession().Value()->IsGroupSession()))
+    {
+        ChipLogError(Controller, "Not allow group session for InvokeRequests that has more than 1 CommandDataIB)");
+        err = CHIP_ERROR_INVALID_ARGUMENT;
+        goto exit;
+    }
+
+    commandSender = Platform::New<app::CommandSender>(callback, device->GetExchangeManager(), timedRequestTimeoutMs != 0);
+    config.SetRemoteMaxPathsPerInvoke(device->GetSecureSession().Value()->GetRemoteSessionParameters().GetMaxPathsPerInvoke());
+    SuccessOrExit(err = commandSender->SetCommandSenderConfig(config));
+
+    for (uint8_t i = 0; i < listSize; i++)
+    {
+        jmethodID getEndpointIdMethod     = nullptr;
+        jmethodID getClusterIdMethod      = nullptr;
+        jmethodID getCommandIdMethod      = nullptr;
+        jmethodID getGroupIdMethod        = nullptr;
+        jmethodID getTlvByteArrayMethod   = nullptr;
+        jmethodID getJsonStringMethod     = nullptr;
+        jmethodID isEndpointIdValidMethod = nullptr;
+        jmethodID isGroupIdValidMethod    = nullptr;
+        jlong endpointIdObj               = 0;
+        jlong clusterIdObj                = 0;
+        jlong commandIdObj                = 0;
+        jobject groupIdObj                = nullptr;
+        jbyteArray tlvBytesObj            = nullptr;
+        jobject invokeElement             = nullptr;
+        SuccessOrExit(err = JniReferences::GetInstance().GetListItem(invokeElementList, i, invokeElement));
+        SuccessOrExit(
+            err = JniReferences::GetInstance().FindMethod(env, invokeElement, "getEndpointId", "(J)J", &getEndpointIdMethod));
+        SuccessOrExit(err =
+                          JniReferences::GetInstance().FindMethod(env, invokeElement, "getClusterId", "(J)J", &getClusterIdMethod));
+        SuccessOrExit(err =
+                          JniReferences::GetInstance().FindMethod(env, invokeElement, "getCommandId", "(J)J", &getCommandIdMethod));
+        SuccessOrExit(err = JniReferences::GetInstance().FindMethod(env, invokeElement, "getGroupId", "()Ljava/util/Optional;",
+                                                                    &getGroupIdMethod));
+        SuccessOrExit(err = JniReferences::GetInstance().FindMethod(env, invokeElement, "isEndpointIdValid", "()Z",
+                                                                    &isEndpointIdValidMethod));
+        SuccessOrExit(
+            err = JniReferences::GetInstance().FindMethod(env, invokeElement, "isGroupIdValid", "()Z", &isGroupIdValidMethod));
+        SuccessOrExit(
+            err = JniReferences::GetInstance().FindMethod(env, invokeElement, "getTlvByteArray", "()[B", &getTlvByteArrayMethod));
+
+        isEndpointIdValid = (env->CallBooleanMethod(invokeElement, isEndpointIdValidMethod) == JNI_TRUE);
+        isGroupIdValid    = (env->CallBooleanMethod(invokeElement, isGroupIdValidMethod) == JNI_TRUE);
+
+        if (isEndpointIdValid)
+        {
+            endpointIdObj = env->CallLongMethod(invokeElement, getEndpointIdMethod, static_cast<jlong>(kInvalidEndpointId));
+            VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+        }
+
+        if (isGroupIdValid)
+        {
+            VerifyOrExit(device->GetSecureSession().Value()->IsGroupSession(), err = CHIP_ERROR_INVALID_ARGUMENT);
+            groupIdObj = env->CallObjectMethod(invokeElement, getGroupIdMethod);
+            VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+            VerifyOrExit(groupIdObj != nullptr, err = CHIP_ERROR_INVALID_ARGUMENT);
+
+            jobject boxedGroupId = nullptr;
+
+            SuccessOrExit(err = JniReferences::GetInstance().GetOptionalValue(groupIdObj, boxedGroupId));
+            VerifyOrExit(boxedGroupId != nullptr, err = CHIP_ERROR_INVALID_ARGUMENT);
+            groupId = static_cast<uint16_t>(JniReferences::GetInstance().IntegerToPrimitive(boxedGroupId));
+        }
+
+        clusterIdObj = env->CallLongMethod(invokeElement, getClusterIdMethod, static_cast<jlong>(kInvalidClusterId));
+        VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+
+        commandIdObj = env->CallLongMethod(invokeElement, getCommandIdMethod, static_cast<jlong>(kInvalidCommandId));
+        VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+
+        tlvBytesObj = static_cast<jbyteArray>(env->CallObjectMethod(invokeElement, getTlvByteArrayMethod));
+        VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+
+        app::CommandSender::PrepareCommandParameters prepareCommandParams;
+        prepareCommandParams.commandRef.SetValue(static_cast<uint16_t>(i));
+
+        {
+            uint16_t id = isEndpointIdValid ? static_cast<uint16_t>(endpointIdObj) : groupId;
+            app::CommandPathFlags flag =
+                isEndpointIdValid ? app::CommandPathFlags::kEndpointIdValid : app::CommandPathFlags::kGroupIdValid;
+            app::CommandPathParams path(id, static_cast<ClusterId>(clusterIdObj), static_cast<CommandId>(commandIdObj), flag);
+
+            if (tlvBytesObj != nullptr)
+            {
+                JniByteArray tlvBytesObjBytes(env, tlvBytesObj);
+                SuccessOrExit(
+                    err = PutPreencodedInvokeRequest(*commandSender, path, tlvBytesObjBytes.byteSpan(), prepareCommandParams));
+            }
+            else
+            {
+                SuccessOrExit(err = JniReferences::GetInstance().FindMethod(env, invokeElement, "getJsonString",
+                                                                            "()Ljava/lang/String;", &getJsonStringMethod));
+                jstring jsonJniString = static_cast<jstring>(env->CallObjectMethod(invokeElement, getJsonStringMethod));
+                VerifyOrExit(!env->ExceptionCheck(), err = CHIP_JNI_ERROR_EXCEPTION_THROWN);
+                VerifyOrExit(jsonJniString != nullptr, err = CHIP_ERROR_INVALID_ARGUMENT);
+                JniUtfString jsonUtfJniString(env, jsonJniString);
+                // The invoke does not support chunk, kMaxSecureSduLengthBytes should be enough for command json blob
+                uint8_t tlvBytes[chip::app::kMaxSecureSduLengthBytes] = { 0 };
+                MutableByteSpan tlvEncodingLocal{ tlvBytes };
+                SuccessOrExit(err = JsonToTlv(std::string(jsonUtfJniString.c_str(), static_cast<size_t>(jsonUtfJniString.size())),
+                                              tlvEncodingLocal));
+                SuccessOrExit(err = PutPreencodedInvokeRequest(*commandSender, path, tlvEncodingLocal, prepareCommandParams));
+            }
+        }
+
+        app::CommandSender::FinishCommandParameters finishCommandParams(convertedTimedRequestTimeoutMs != 0
+                                                                            ? Optional<uint16_t>(convertedTimedRequestTimeoutMs)
+                                                                            : Optional<uint16_t>::Missing());
+
+        finishCommandParams.commandRef = prepareCommandParams.commandRef;
+        SuccessOrExit(err = commandSender->FinishCommand(finishCommandParams));
+    }
+    SuccessOrExit(err = device->GetSecureSession().Value()->IsGroupSession()
+                      ? commandSender->SendGroupCommandRequest(device->GetSecureSession().Value())
+                      : commandSender->SendCommandRequest(device->GetSecureSession().Value(),
+                                                          imTimeoutMs != 0
+                                                              ? MakeOptional(System::Clock::Milliseconds32(imTimeoutMs))
+                                                              : Optional<System::Clock::Timeout>::Missing()));
+
+    callback->mCommandSender = commandSender;
+exit:
+    if (err != CHIP_NO_ERROR)
+    {
+        ChipLogError(Controller, "JNI IM Invoke Error: %s", err.AsString());
+        if (err == CHIP_JNI_ERROR_EXCEPTION_THROWN)
+        {
+            env->ExceptionDescribe();
+            env->ExceptionClear();
+        }
+        app::CommandSender::ErrorData errorData;
+        errorData.error = err;
+        callback->OnError(nullptr, errorData);
+        if (commandSender != nullptr)
+        {
+            Platform::Delete(commandSender);
+            commandSender = nullptr;
+        }
+        if (callback != nullptr)
+        {
+            Platform::Delete(callback);
+            callback = nullptr;
+        }
+    }
+    return err;
+}
+
 CHIP_ERROR invoke(JNIEnv * env, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElement,
                   jint timedRequestTimeoutMs, jint imTimeoutMs)
 {
diff --git a/src/controller/java/AndroidInteractionClient.h b/src/controller/java/AndroidInteractionClient.h
index 095061f..f38af6f 100644
--- a/src/controller/java/AndroidInteractionClient.h
+++ b/src/controller/java/AndroidInteractionClient.h
@@ -29,3 +29,5 @@
                  jint timedRequestTimeoutMs, jint imTimeoutMs);
 CHIP_ERROR invoke(JNIEnv * env, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElement,
                   jint timedRequestTimeoutMs, jint imTimeoutMs);
+CHIP_ERROR extendableInvoke(JNIEnv * env, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElementList,
+                            jint timedRequestTimeoutMs, jint imTimeoutMs);
diff --git a/src/controller/java/BUILD.gn b/src/controller/java/BUILD.gn
index 31dbaaf..a2f3c86 100644
--- a/src/controller/java/BUILD.gn
+++ b/src/controller/java/BUILD.gn
@@ -459,6 +459,8 @@
     "src/chip/devicecontroller/ControllerParams.java",
     "src/chip/devicecontroller/DeviceAttestationDelegate.java",
     "src/chip/devicecontroller/DiscoveredDevice.java",
+    "src/chip/devicecontroller/ExtendableInvokeCallback.java",
+    "src/chip/devicecontroller/ExtendableInvokeCallbackJni.java",
     "src/chip/devicecontroller/GetConnectedDeviceCallbackJni.java",
     "src/chip/devicecontroller/GroupKeySecurityPolicy.java",
     "src/chip/devicecontroller/ICDClientInfo.java",
@@ -493,6 +495,8 @@
     "src/chip/devicecontroller/model/EndpointState.java",
     "src/chip/devicecontroller/model/EventState.java",
     "src/chip/devicecontroller/model/InvokeElement.java",
+    "src/chip/devicecontroller/model/InvokeResponseData.java",
+    "src/chip/devicecontroller/model/NoInvokeResponseData.java",
     "src/chip/devicecontroller/model/NodeState.java",
     "src/chip/devicecontroller/model/Status.java",
   ]
diff --git a/src/controller/java/CHIPDeviceController-JNI.cpp b/src/controller/java/CHIPDeviceController-JNI.cpp
index 206103c..fc2c069 100644
--- a/src/controller/java/CHIPDeviceController-JNI.cpp
+++ b/src/controller/java/CHIPDeviceController-JNI.cpp
@@ -2285,6 +2285,18 @@
     }
 }
 
+JNI_METHOD(void, extendableInvoke)
+(JNIEnv * env, jclass clz, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElementList,
+ jint timedRequestTimeoutMs, jint imTimeoutMs)
+{
+    CHIP_ERROR err =
+        extendableInvoke(env, handle, callbackHandle, devicePtr, invokeElementList, timedRequestTimeoutMs, imTimeoutMs);
+    if (err != CHIP_NO_ERROR)
+    {
+        ChipLogError(Controller, "JNI IM Batch Invoke Error: %" CHIP_ERROR_FORMAT, err.Format());
+    }
+}
+
 void * IOThreadMain(void * arg)
 {
     JNIEnv * env;
diff --git a/src/controller/java/MatterCallbacks-JNI.cpp b/src/controller/java/MatterCallbacks-JNI.cpp
index f20d9e4..93101df 100644
--- a/src/controller/java/MatterCallbacks-JNI.cpp
+++ b/src/controller/java/MatterCallbacks-JNI.cpp
@@ -69,3 +69,14 @@
 {
     deleteInvokeCallback(env, self, callbackHandle);
 }
+
+JNI_METHOD(jlong, ExtendableInvokeCallbackJni, newCallback)
+(JNIEnv * env, jobject self)
+{
+    return newExtendableInvokeCallback(env, self);
+}
+
+JNI_METHOD(void, ExtendableInvokeCallbackJni, deleteCallback)(JNIEnv * env, jobject self, jlong callbackHandle)
+{
+    deleteExtendableInvokeCallback(env, self, callbackHandle);
+}
diff --git a/src/controller/java/MatterInteractionClient-JNI.cpp b/src/controller/java/MatterInteractionClient-JNI.cpp
index 6a54172..152e52a 100644
--- a/src/controller/java/MatterInteractionClient-JNI.cpp
+++ b/src/controller/java/MatterInteractionClient-JNI.cpp
@@ -65,3 +65,15 @@
         ChipLogError(Controller, "JNI IM Invoke Error: %" CHIP_ERROR_FORMAT, err.Format());
     }
 }
+
+JNI_METHOD(void, extendableInvoke)
+(JNIEnv * env, jobject self, jlong handle, jlong callbackHandle, jlong devicePtr, jobject invokeElementList,
+ jint timedRequestTimeoutMs, jint imTimeoutMs)
+{
+    CHIP_ERROR err =
+        extendableInvoke(env, handle, callbackHandle, devicePtr, invokeElementList, timedRequestTimeoutMs, imTimeoutMs);
+    if (err != CHIP_NO_ERROR)
+    {
+        ChipLogError(Controller, "JNI IM Batch Invoke Error: %" CHIP_ERROR_FORMAT, err.Format());
+    }
+}
diff --git a/src/controller/java/src/chip/devicecontroller/BatchInvokeCallback.java b/src/controller/java/src/chip/devicecontroller/BatchInvokeCallback.java
new file mode 100644
index 0000000..1b07e38
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/BatchInvokeCallback.java
@@ -0,0 +1,54 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller;
+
+import chip.devicecontroller.model.InvokeResponseData;
+import chip.devicecontroller.model.NoInvokeResponseData;
+
+/** An interface for receiving invoke response. */
+public interface ExtendableInvokeCallback {
+
+  /**
+   * OnError will be called when an error occurs after failing to call
+   *
+   * @param Exception The IllegalStateException which encapsulated the error message, the possible
+   *     chip error could be - CHIP_ERROR_TIMEOUT: A response was not received within the expected
+   *     response timeout. - CHIP_ERROR_*TLV*: A malformed, non-compliant response was received from
+   *     the server. - CHIP_ERROR encapsulating the converted error from the StatusIB: If we got a
+   *     non-path-specific status response from the server. - CHIP_ERROR*: All other cases.
+   */
+  void onError(Exception e);
+
+  /**
+   * OnResponse will be called when a write response has been received and processed for the given
+   * path.
+   *
+   * @param invokeResponseData invoke response that has either payload or status
+   */
+  void onResponse(InvokeResponseData invokeResponseData);
+
+  /**
+   * onNoResponse will be called for each request that failed to receive a response after the server
+   * indicates completion of all requests.
+   *
+   * @param noInvokeResponseData failed response data
+   */
+  void onNoResponse(NoInvokeResponseData noInvokeResponseData);
+
+  void onDone();
+}
diff --git a/src/controller/java/src/chip/devicecontroller/BatchInvokeCallbackJni.java b/src/controller/java/src/chip/devicecontroller/BatchInvokeCallbackJni.java
new file mode 100644
index 0000000..6982f58
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/BatchInvokeCallbackJni.java
@@ -0,0 +1,94 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller;
+
+import chip.devicecontroller.model.InvokeResponseData;
+import chip.devicecontroller.model.NoInvokeResponseData;
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/** JNI wrapper callback class for {@link InvokeCallback}. */
+public final class ExtendableInvokeCallbackJni {
+  private final ExtendableInvokeCallback wrappedExtendableInvokeCallback;
+  private long callbackHandle;
+
+  public ExtendableInvokeCallbackJni(ExtendableInvokeCallback wrappedExtendableInvokeCallback) {
+    this.wrappedExtendableInvokeCallback = wrappedExtendableInvokeCallback;
+    this.callbackHandle = newCallback();
+  }
+
+  long getCallbackHandle() {
+    return callbackHandle;
+  }
+
+  private native long newCallback();
+
+  private native void deleteCallback(long callbackHandle);
+
+  private void onError(Exception e) {
+    wrappedExtendableInvokeCallback.onError(e);
+  }
+
+  private void onResponse(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      @Nullable Integer commandRef,
+      byte[] tlv,
+      String jsonString) {
+    wrappedExtendableInvokeCallback.onResponse(
+        InvokeResponseData.newInstance(
+            endpointId, clusterId, commandId, Optional.ofNullable(commandRef), tlv, jsonString));
+  }
+
+  private void onResponse(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      @Nullable Integer commandRef,
+      int status,
+      @Nullable Integer clusterStatus) {
+    wrappedExtendableInvokeCallback.onResponse(
+        InvokeResponseData.newInstance(
+            endpointId,
+            clusterId,
+            commandId,
+            Optional.ofNullable(commandRef),
+            status,
+            Optional.ofNullable(clusterStatus)));
+  }
+
+  private void onNoResponse(int commandRef) {
+    wrappedExtendableInvokeCallback.onNoResponse(NoInvokeResponseData.newInstance(commandRef));
+  }
+
+  private void onDone() {
+    wrappedExtendableInvokeCallback.onDone();
+  }
+
+  // TODO(#8578): Replace finalizer with PhantomReference.
+  @SuppressWarnings("deprecation")
+  protected void finalize() throws Throwable {
+    super.finalize();
+
+    if (callbackHandle != 0) {
+      deleteCallback(callbackHandle);
+      callbackHandle = 0;
+    }
+  }
+}
diff --git a/src/controller/java/src/chip/devicecontroller/ChipDeviceController.java b/src/controller/java/src/chip/devicecontroller/ChipDeviceController.java
index 9e5c6e1..25d300e 100644
--- a/src/controller/java/src/chip/devicecontroller/ChipDeviceController.java
+++ b/src/controller/java/src/chip/devicecontroller/ChipDeviceController.java
@@ -1205,6 +1205,32 @@
         imTimeoutMs);
   }
 
+  /**
+   * @brief ExtendableInvoke command to target device
+   * @param ExtendableInvokeCallback Callback when invoke responses have been received and processed
+   *     for the given batched invoke commands.
+   * @param devicePtr connected device pointer
+   * @param invokeElementList invoke element list
+   * @param timedRequestTimeoutMs this is timed request if this value is larger than 0
+   * @param imTimeoutMs im interaction time out value, it would override the default value in c++ im
+   *     layer if this value is non-zero.
+   */
+  public void extendableInvoke(
+      ExtendableInvokeCallback callback,
+      long devicePtr,
+      List<InvokeElement> invokeElementList,
+      int timedRequestTimeoutMs,
+      int imTimeoutMs) {
+    ExtendableInvokeCallbackJni jniCallback = new ExtendableInvokeCallbackJni(callback);
+    extendableInvoke(
+        deviceControllerPtr,
+        jniCallback.getCallbackHandle(),
+        devicePtr,
+        invokeElementList,
+        timedRequestTimeoutMs,
+        imTimeoutMs);
+  }
+
   /** Create a root (self-signed) X.509 DER encoded certificate */
   public static byte[] createRootCertificate(
       KeypairDelegate keypair, long issuerId, @Nullable Long fabricId) {
@@ -1377,6 +1403,14 @@
       int timedRequestTimeoutMs,
       int imTimeoutMs);
 
+  static native void extendableInvoke(
+      long deviceControllerPtr,
+      long callbackHandle,
+      long devicePtr,
+      List<InvokeElement> invokeElementList,
+      int timedRequestTimeoutMs,
+      int imTimeoutMs);
+
   private native long newDeviceController(ControllerParams params);
 
   private native void setDeviceAttestationDelegate(
diff --git a/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallback.java b/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallback.java
new file mode 100644
index 0000000..1b07e38
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallback.java
@@ -0,0 +1,54 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller;
+
+import chip.devicecontroller.model.InvokeResponseData;
+import chip.devicecontroller.model.NoInvokeResponseData;
+
+/** An interface for receiving invoke response. */
+public interface ExtendableInvokeCallback {
+
+  /**
+   * OnError will be called when an error occurs after failing to call
+   *
+   * @param Exception The IllegalStateException which encapsulated the error message, the possible
+   *     chip error could be - CHIP_ERROR_TIMEOUT: A response was not received within the expected
+   *     response timeout. - CHIP_ERROR_*TLV*: A malformed, non-compliant response was received from
+   *     the server. - CHIP_ERROR encapsulating the converted error from the StatusIB: If we got a
+   *     non-path-specific status response from the server. - CHIP_ERROR*: All other cases.
+   */
+  void onError(Exception e);
+
+  /**
+   * OnResponse will be called when a write response has been received and processed for the given
+   * path.
+   *
+   * @param invokeResponseData invoke response that has either payload or status
+   */
+  void onResponse(InvokeResponseData invokeResponseData);
+
+  /**
+   * onNoResponse will be called for each request that failed to receive a response after the server
+   * indicates completion of all requests.
+   *
+   * @param noInvokeResponseData failed response data
+   */
+  void onNoResponse(NoInvokeResponseData noInvokeResponseData);
+
+  void onDone();
+}
diff --git a/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallbackJni.java b/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallbackJni.java
new file mode 100644
index 0000000..6982f58
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/ExtendableInvokeCallbackJni.java
@@ -0,0 +1,94 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller;
+
+import chip.devicecontroller.model.InvokeResponseData;
+import chip.devicecontroller.model.NoInvokeResponseData;
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/** JNI wrapper callback class for {@link InvokeCallback}. */
+public final class ExtendableInvokeCallbackJni {
+  private final ExtendableInvokeCallback wrappedExtendableInvokeCallback;
+  private long callbackHandle;
+
+  public ExtendableInvokeCallbackJni(ExtendableInvokeCallback wrappedExtendableInvokeCallback) {
+    this.wrappedExtendableInvokeCallback = wrappedExtendableInvokeCallback;
+    this.callbackHandle = newCallback();
+  }
+
+  long getCallbackHandle() {
+    return callbackHandle;
+  }
+
+  private native long newCallback();
+
+  private native void deleteCallback(long callbackHandle);
+
+  private void onError(Exception e) {
+    wrappedExtendableInvokeCallback.onError(e);
+  }
+
+  private void onResponse(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      @Nullable Integer commandRef,
+      byte[] tlv,
+      String jsonString) {
+    wrappedExtendableInvokeCallback.onResponse(
+        InvokeResponseData.newInstance(
+            endpointId, clusterId, commandId, Optional.ofNullable(commandRef), tlv, jsonString));
+  }
+
+  private void onResponse(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      @Nullable Integer commandRef,
+      int status,
+      @Nullable Integer clusterStatus) {
+    wrappedExtendableInvokeCallback.onResponse(
+        InvokeResponseData.newInstance(
+            endpointId,
+            clusterId,
+            commandId,
+            Optional.ofNullable(commandRef),
+            status,
+            Optional.ofNullable(clusterStatus)));
+  }
+
+  private void onNoResponse(int commandRef) {
+    wrappedExtendableInvokeCallback.onNoResponse(NoInvokeResponseData.newInstance(commandRef));
+  }
+
+  private void onDone() {
+    wrappedExtendableInvokeCallback.onDone();
+  }
+
+  // TODO(#8578): Replace finalizer with PhantomReference.
+  @SuppressWarnings("deprecation")
+  protected void finalize() throws Throwable {
+    super.finalize();
+
+    if (callbackHandle != 0) {
+      deleteCallback(callbackHandle);
+      callbackHandle = 0;
+    }
+  }
+}
diff --git a/src/controller/java/src/chip/devicecontroller/model/InvokeResponseData.java b/src/controller/java/src/chip/devicecontroller/model/InvokeResponseData.java
new file mode 100644
index 0000000..2630515
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/model/InvokeResponseData.java
@@ -0,0 +1,224 @@
+/*
+ *   Copyright (c) 2023 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller.model;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** Class for tracking invoke response data with either data or status */
+public final class InvokeResponseData {
+  private static final Logger logger = Logger.getLogger(InvokeResponseData.class.getName());
+  @Nullable private final ChipPathId endpointId;
+  private final ChipPathId clusterId, commandId;
+  private final Optional<Integer> commandRef;
+  @Nullable private final byte[] tlv;
+  @Nullable private final JSONObject json;
+  @Nullable private final Status status;
+
+  private InvokeResponseData(
+      ChipPathId endpointId,
+      ChipPathId clusterId,
+      ChipPathId commandId,
+      Optional<Integer> commandRef,
+      @Nullable byte[] tlv,
+      @Nullable String jsonString) {
+    this.endpointId = endpointId;
+    this.clusterId = clusterId;
+    this.commandId = commandId;
+    this.commandRef = commandRef;
+
+    if (tlv != null) {
+      this.tlv = tlv.clone();
+    } else {
+      this.tlv = null;
+    }
+
+    JSONObject jsonObject = null;
+    if (jsonString != null) {
+      try {
+        jsonObject = new JSONObject(jsonString);
+      } catch (JSONException ex) {
+        logger.log(Level.SEVERE, "Error parsing JSON string", ex);
+      }
+    }
+
+    this.json = jsonObject;
+    this.status = null;
+  }
+
+  private InvokeResponseData(
+      ChipPathId endpointId,
+      ChipPathId clusterId,
+      ChipPathId commandId,
+      Optional<Integer> commandRef,
+      int status,
+      Optional<Integer> clusterStatus) {
+    this.endpointId = endpointId;
+    this.clusterId = clusterId;
+    this.commandId = commandId;
+    this.commandRef = commandRef;
+    this.status = Status.newInstance(status, clusterStatus);
+    this.tlv = null;
+    this.json = null;
+  }
+
+  public ChipPathId getEndpointId() {
+    return endpointId;
+  }
+
+  public ChipPathId getClusterId() {
+    return clusterId;
+  }
+
+  public ChipPathId getCommandId() {
+    return commandId;
+  }
+
+  public Optional<Integer> getCommandRef() {
+    return commandRef;
+  }
+
+  @Nullable
+  public Status getStatus() {
+    return status;
+  }
+
+  // For use in JNI.
+  private long getEndpointId(long wildcardValue) {
+    return endpointId.getId(wildcardValue);
+  }
+
+  private long getClusterId(long wildcardValue) {
+    return clusterId.getId(wildcardValue);
+  }
+
+  private long getCommandId(long wildcardValue) {
+    return commandId.getId(wildcardValue);
+  }
+
+  public boolean isEndpointIdValid() {
+    return endpointId != null;
+  }
+
+  @Nullable
+  public byte[] getTlvByteArray() {
+    if (tlv != null) {
+      return tlv.clone();
+    }
+    return null;
+  }
+
+  @Nullable
+  public JSONObject getJsonObject() {
+    return json;
+  }
+
+  @Nullable
+  public String getJsonString() {
+    if (json == null) return null;
+    return json.toString();
+  }
+
+  // check whether the current InvokeResponseData has same path as others.
+  @Override
+  public boolean equals(Object object) {
+    if (object instanceof InvokeResponseData) {
+      InvokeResponseData that = (InvokeResponseData) object;
+      return Objects.equals(this.endpointId, that.endpointId)
+          && Objects.equals(this.clusterId, that.clusterId)
+          && Objects.equals(this.commandId, that.commandId);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(endpointId, clusterId, commandId);
+  }
+
+  @Override
+  public String toString() {
+    return String.format(
+        Locale.ENGLISH,
+        "Endpoint %s, cluster %s, command %s, payload: %s, status: %s",
+        endpointId,
+        clusterId,
+        commandId,
+        json == null ? "null" : getJsonString(),
+        status == null ? "null" : status.toString());
+  }
+
+  public static InvokeResponseData newInstance(
+      ChipPathId endpointId,
+      ChipPathId clusterId,
+      ChipPathId commandId,
+      Optional<Integer> commandRef,
+      @Nullable byte[] tlv,
+      @Nullable String jsonString) {
+    return new InvokeResponseData(endpointId, clusterId, commandId, commandRef, tlv, jsonString);
+  }
+
+  public static InvokeResponseData newInstance(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      Optional<Integer> commandRef,
+      @Nullable byte[] tlv,
+      @Nullable String jsonString) {
+    return new InvokeResponseData(
+        ChipPathId.forId(endpointId),
+        ChipPathId.forId(clusterId),
+        ChipPathId.forId(commandId),
+        commandRef,
+        tlv,
+        jsonString);
+  }
+
+  public static InvokeResponseData newInstance(
+      ChipPathId endpointId,
+      ChipPathId clusterId,
+      ChipPathId commandId,
+      Optional<Integer> commandRef,
+      int status,
+      Optional<Integer> clusterStatus) {
+    return new InvokeResponseData(
+        endpointId, clusterId, commandId, commandRef, status, clusterStatus);
+  }
+
+  public static InvokeResponseData newInstance(
+      int endpointId,
+      long clusterId,
+      long commandId,
+      Optional<Integer> commandRef,
+      int status,
+      Optional<Integer> clusterStatus) {
+    return new InvokeResponseData(
+        ChipPathId.forId(endpointId),
+        ChipPathId.forId(clusterId),
+        ChipPathId.forId(commandId),
+        commandRef,
+        status,
+        clusterStatus);
+  }
+}
diff --git a/src/controller/java/src/chip/devicecontroller/model/NoInvokeResponseData.java b/src/controller/java/src/chip/devicecontroller/model/NoInvokeResponseData.java
new file mode 100644
index 0000000..03e930c
--- /dev/null
+++ b/src/controller/java/src/chip/devicecontroller/model/NoInvokeResponseData.java
@@ -0,0 +1,38 @@
+/*
+ *   Copyright (c) 2024 Project CHIP Authors
+ *   All rights reserved.
+ *
+ *   Licensed under the Apache License, Version 2.0 (the "License");
+ *   you may not use this file except in compliance with the License.
+ *   You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   Unless required by applicable law or agreed to in writing, software
+ *   distributed under the License is distributed on an "AS IS" BASIS,
+ *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *   See the License for the specific language governing permissions and
+ *   limitations under the License.
+ *
+ */
+package chip.devicecontroller.model;
+
+import java.util.logging.Logger;
+
+/** Class for tracking failed invoke response data. */
+public final class NoInvokeResponseData {
+  private static final Logger logger = Logger.getLogger(NoInvokeResponseData.class.getName());
+  private final Integer commandRef;
+
+  private NoInvokeResponseData(int commandRef) {
+    this.commandRef = commandRef;
+  }
+
+  public Integer getCommandRef() {
+    return commandRef;
+  }
+
+  public static NoInvokeResponseData newInstance(int commandRef) {
+    return new NoInvokeResponseData(commandRef);
+  }
+}
diff --git a/src/controller/java/src/chip/devicecontroller/model/Status.java b/src/controller/java/src/chip/devicecontroller/model/Status.java
index d859fae..28abd36 100644
--- a/src/controller/java/src/chip/devicecontroller/model/Status.java
+++ b/src/controller/java/src/chip/devicecontroller/model/Status.java
@@ -124,4 +124,8 @@
   public static Status newInstance(int status, Integer clusterStatus) {
     return new Status(status, Optional.ofNullable(clusterStatus));
   }
+
+  public static Status newInstance(int status, Optional<Integer> clusterStatus) {
+    return new Status(status, clusterStatus);
+  }
 }
diff --git a/src/lib/core/core.gni b/src/lib/core/core.gni
index 8c7032e..02f98e3 100644
--- a/src/lib/core/core.gni
+++ b/src/lib/core/core.gni
@@ -100,7 +100,8 @@
   chip_tlv_validate_char_string_on_read = false
 
   chip_enable_sending_batch_commands =
-      current_os == "linux" || current_os == "mac" || current_os == "ios"
+      current_os == "linux" || current_os == "mac" || current_os == "ios" ||
+      current_os == "android"
 }
 
 if (chip_target_style == "") {