DoggyZhang 1 год назад
Родитель
Сommit
11035ba306
100 измененных файлов с 10451 добавлено и 0 удалено
  1. 1 0
      app/build.gradle
  2. 1 0
      frame/tuicallkit/.gitignore
  3. 56 0
      frame/tuicallkit/build.gradle
  4. 10 0
      frame/tuicallkit/build/generated/source/buildConfig/debug/com/tencent/qcloud/tuikit/tuicallkit/BuildConfig.java
  5. 21 0
      frame/tuicallkit/proguard-rules.pro
  6. 66 0
      frame/tuicallkit/src/main/AndroidManifest.xml
  7. 140 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/TUICallKit.kt
  8. 429 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/TUICallKitImpl.kt
  9. 34 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/Constants.kt
  10. 29 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/OfflinePushInfoConfig.kt
  11. 78 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/User.kt
  12. 172 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/debug/GenerateTestUserSig.kt
  13. 222 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/CallingBellFeature.kt
  14. 55 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/CallingVibratorFeature.kt
  15. 38 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/NotificationFeature.kt
  16. 8 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/GroupMemberInfo.kt
  17. 133 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/SelectGroupMemberActivity.kt
  18. 69 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/SelectGroupMemberAdapter.kt
  19. 128 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/joiningroupcall/JoinInGroupCallView.kt
  20. 169 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/joiningroupcall/JoinInGroupCallViewModel.kt
  21. 298 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsFragment.kt
  22. 116 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsItemHolder.kt
  23. 192 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsListAdapter.kt
  24. 76 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsViewModel.kt
  25. 70 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecordsIconView.kt
  26. 10 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/interfaces/ICallRecordItemListener.kt
  27. 141 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/ServiceInitializer.kt
  28. 306 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/TUIAudioMessageRecordService.kt
  29. 483 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/TUICallKitService.kt
  30. 583 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/manager/EngineManager.kt
  31. 519 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/state/TUICallState.kt
  32. 251 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/BlurUtils.kt
  33. 62 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/DeviceUtils.kt
  34. 161 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/ImageLoader.kt
  35. 68 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/Logger.kt
  36. 188 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/PermissionRequest.kt
  37. 128 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/UserInfoUtils.kt
  38. 202 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/CallKitActivity.kt
  39. 110 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/CustomLoadingView.kt
  40. 98 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/RoundCornerImageView.kt
  41. 134 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/RoundShadowLayout.kt
  42. 234 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/SlideRecyclerView.kt
  43. 67 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/GridImageData.kt
  44. 328 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/GridImageSynthesizer.kt
  45. 29 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/ThreadUtils.kt
  46. 53 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/CallTimerView.kt
  47. 103 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/CallWaitingHintView.kt
  48. 99 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/InviteUserButton.kt
  49. 227 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatWindowService.kt
  50. 73 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowButton.kt
  51. 186 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowGroupView.kt
  52. 141 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowView.kt
  53. 38 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/AudioAndVideoCalleeWaitingView.kt
  54. 109 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/AudioCallerWaitingAndAcceptedView.kt
  55. 231 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/VideoCallerAndCalleeAcceptedView.kt
  56. 116 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/VideoCallerWaitingView.kt
  57. 15 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingCallReceiver.kt
  58. 190 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingFloatView.kt
  59. 160 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingNotificationView.kt
  60. 47 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/group/GroupCallerUserInfoView.kt
  61. 119 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/group/InviteeAvatarListView.kt
  62. 66 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/single/AudioCallUserInfoView.kt
  63. 86 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/single/VideoCallUserInfoView.kt
  64. 309 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/GroupCallFlowLayout.kt
  65. 141 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/GroupCallVideoLayout.kt
  66. 215 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/SingleCallVideoLayout.kt
  67. 315 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/VideoView.kt
  68. 65 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/VideoViewFactory.kt
  69. 8 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/BaseCallView.kt
  70. 244 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/GroupCallView.kt
  71. 211 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/SingleCallView.kt
  72. 33 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/floatview/FloatingWindowViewModel.kt
  73. 70 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/GroupCallVideoLayoutViewModel.kt
  74. 45 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/SingleCallVideoLayoutViewModel.kt
  75. 23 0
      frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/VideoViewModel.kt
  76. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_chat_title_bar_minimalist_audio_call_icon.png
  77. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_chat_title_bar_minimalist_video_call_icon.png
  78. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_check_box_group_selected.png
  79. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_check_box_group_unselected.png
  80. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_checkbox_selected.png
  81. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_group_select_disable.png
  82. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_add_user_black.png
  83. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_audio_call.png
  84. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_audio_input.png
  85. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_avatar.png
  86. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_back.png
  87. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_accept.png
  88. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_waiting_disable.png
  89. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_waiting_enable.png
  90. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_camera_disable.png
  91. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_camera_enable.png
  92. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_delete.png
  93. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing.png
  94. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing_pressed.png
  95. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing_video.png
  96. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_edit.png
  97. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float.png
  98. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_audio_off.png
  99. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_audio_on.png
  100. BIN
      frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_video_off.png

+ 1 - 0
app/build.gradle

@@ -397,6 +397,7 @@ dependencies {
     api libs.frame.debug
 
     api project(":frame:room")
+    api project(":frame:tuicallkit")
     api libs.frame.locale
     api libs.frame.push
     api libs.frame.media

+ 1 - 0
frame/tuicallkit/.gitignore

@@ -0,0 +1 @@
+/build

+ 56 - 0
frame/tuicallkit/build.gradle

@@ -0,0 +1,56 @@
+plugins {
+    id 'com.android.library'
+    id 'org.jetbrains.kotlin.android'
+    id 'kotlin-parcelize'
+}
+
+android {
+    namespace "com.tencent.qcloud.tuikit.tuicallkit"
+    compileSdk libs.versions.compileSdk.get().toInteger()
+
+    defaultConfig {
+        minSdk libs.versions.minSdk.get().toInteger()
+        targetSdk libs.versions.targetSdk.get().toInteger()
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        consumerProguardFiles "consumer-rules.pro"
+        ndk {
+            abiFilters "armeabi-v7a"
+            abiFilters "arm64-v8a"
+        }
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_17
+        targetCompatibility JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.majorVersion
+    }
+}
+
+dependencies {
+    //kotlin
+    implementation libs.kotlin.stdlib
+
+    //androidx
+    implementation libs.androidx.core.ktx
+    implementation libs.androidx.appcompat
+    implementation libs.android.material
+    implementation libs.androidx.recyclerview
+    implementation libs.androidx.constraint.layout
+    implementation libs.gson
+
+    api libs.tencent.imsdk
+    api libs.tencent.tui.core
+    api libs.tencent.uikit.common
+    api libs.trtc
+    //api libs.tencent.liteav.sdk
+    api libs.tencent.uikit.room.engine
+}

+ 10 - 0
frame/tuicallkit/build/generated/source/buildConfig/debug/com/tencent/qcloud/tuikit/tuicallkit/BuildConfig.java

@@ -0,0 +1,10 @@
+/**
+ * Automatically generated file. DO NOT MODIFY
+ */
+package com.tencent.qcloud.tuikit.tuicallkit;
+
+public final class BuildConfig {
+  public static final boolean DEBUG = Boolean.parseBoolean("true");
+  public static final String LIBRARY_PACKAGE_NAME = "com.tencent.qcloud.tuikit.tuicallkit";
+  public static final String BUILD_TYPE = "debug";
+}

+ 21 - 0
frame/tuicallkit/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 66 - 0
frame/tuicallkit/src/main/AndroidManifest.xml

@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
+    <uses-permission android:name="android.permission.VIBRATE" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+    <uses-permission android:name="android.permission.CAMERA" />
+
+    <uses-feature android:name="android.hardware.camera" />
+    <uses-feature android:name="android.hardware.camera.autofocus" />
+
+    <uses-permission
+        android:name="android.permission.BLUETOOTH"
+        android:maxSdkVersion="30" />
+    <uses-permission
+        android:name="android.permission.BLUETOOTH_ADMIN"
+        android:maxSdkVersion="30" />
+    <!-- Support Android S(31) Bluetooth-->
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+
+    <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
+
+    <application>
+        <activity
+            android:name="com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity"
+            android:configChanges="orientation|screenSize"
+            android:launchMode="singleTask"
+            android:theme="@style/Theme.AppCompat.NoActionBar" />
+
+        <provider
+            android:name="com.tencent.qcloud.tuikit.tuicallkit.internal.ServiceInitializer"
+            android:authorities="${applicationId}.ServiceInitializer"
+            android:enabled="true"
+            android:exported="false" />
+
+        <service
+            android:name="com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatWindowService"
+            android:enabled="true"
+            android:exported="false" />
+
+        <activity
+            android:name=".extensions.inviteuser.SelectGroupMemberActivity"
+            android:configChanges="orientation|screenSize"
+            android:launchMode="singleTask"
+            android:theme="@style/Theme.AppCompat.NoActionBar" />
+
+        <receiver
+            android:name="com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview.IncomingCallReceiver"
+            android:enabled="true"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="reject_call_action" />
+            </intent-filter>
+        </receiver>
+    </application>
+
+</manifest>

+ 140 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/TUICallKit.kt

@@ -0,0 +1,140 @@
+package com.tencent.qcloud.tuikit.tuicallkit
+
+import android.content.Context
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+
+abstract class TUICallKit {
+    companion object {
+        @JvmStatic
+        fun createInstance(context: Context): TUICallKit = TUICallKitImpl.createInstance(context)
+    }
+
+    /**
+     * Set user profile
+     *
+     * @param nickname User name, which can contain up to 500 bytes
+     * @param avatar   User profile photo URL, which can contain up to 500 bytes
+     * For example: https://liteav.sdk.qcloud.com/app/res/picture/voiceroom/avatar/user_avatar1.png
+     * @param callback Set the result callback
+     */
+    open fun setSelfInfo(nickname: String?, avatar: String?, callback: TUICommonDefine.Callback?) {}
+
+    /**
+     * Make a call
+     *
+     * @param userId        callees
+     * @param callMediaType Call type
+     */
+    @Deprecated("Use NewInterface instead", ReplaceWith("calls"))
+    open fun call(userId: String, callMediaType: TUICallDefine.MediaType) {
+    }
+
+    /**
+     * Make a call
+     *
+     * @param userId        callees
+     * @param callMediaType Call type
+     * @param params        Extension param: eg: offlinePushInfo
+     */
+    @Deprecated("Use NewInterface instead", ReplaceWith("calls"))
+    open fun call(
+        userId: String, callMediaType: TUICallDefine.MediaType,
+        params: TUICallDefine.CallParams?, callback: TUICommonDefine.Callback?
+    ) {
+    }
+
+    /**
+     * Make a group call
+     *
+     * @param groupId       GroupId
+     * @param userIdList    List of userId
+     * @param callMediaType Call type
+     */
+    @Deprecated("Use NewInterface instead", ReplaceWith("calls"))
+    open fun groupCall(groupId: String, userIdList: List<String?>?, callMediaType: TUICallDefine.MediaType) {
+    }
+
+    /**
+     * Make a group call
+     *
+     * @param groupId       GroupId
+     * @param userIdList    List of userId
+     * @param callMediaType Call type
+     * @param params        Extension param: eg: offlinePushInfo
+     */
+    @Deprecated("Use NewInterface instead", ReplaceWith("calls"))
+    open fun groupCall(
+        groupId: String, userIdList: List<String?>?,
+        callMediaType: TUICallDefine.MediaType, params: TUICallDefine.CallParams?,
+        callback: TUICommonDefine.Callback?
+    ) {
+    }
+
+    /**
+     * Make a 1VN calls
+     *
+     * @param userIdList    List of userId
+     * @param mediaType     Call type
+     * @param params        Extension param: eg: offlinePushInfo
+     */
+    open fun calls(
+        userIdList: List<String?>?, mediaType: TUICallDefine.MediaType?, params: TUICallDefine.CallParams?,
+        callback: TUICommonDefine.Callback?
+    ) {
+    }
+
+    /**
+     * Join a current call
+     *
+     * @param callId current call ID
+     */
+    open fun join(callId: String?, callback: TUICommonDefine.Callback?) {}
+
+    /**
+     * Join a current call
+     *
+     * @param roomId        current call room ID
+     * @param callMediaType call type
+     */
+    @Deprecated("Use NewInterface instead", ReplaceWith("join(callId,callback)"))
+    open fun joinInGroupCall(
+        roomId: TUICommonDefine.RoomId?, groupId: String?, callMediaType: TUICallDefine.MediaType?
+    ) {
+    }
+
+    /**
+     * Set the ringtone (preferably shorter than 30s)
+     *
+     * @param filePath Callee ringtone path
+     */
+    open fun setCallingBell(filePath: String?) {}
+
+    /**
+     * Enable the mute mode (the callee doesn't ring)
+     */
+    open fun enableMuteMode(enable: Boolean) {}
+
+    /**
+     * Enable the floating window
+     */
+    open fun enableFloatWindow(enable: Boolean) {}
+
+    /**
+     * Enable Virtual Background
+     */
+    open fun enableVirtualBackground(enable: Boolean) {}
+
+    /**
+     * Enable callee show banner view when received an new invitation
+     * default: false
+     */
+    open fun enableIncomingBanner(enable: Boolean) {}
+
+    /**
+     * Set the display direction of the CallKit interface. The default value is portrait
+     * @param orientation:  0-Portrait, 1-LandScape, 2-Auto;   default value: 0
+     * Note: You are advised to use portrait mode to avoid abnormal display for small screen devices such as mobile phone
+     */
+    open fun setScreenOrientation(orientation: Int) {}
+}

+ 429 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/TUICallKitImpl.kt

@@ -0,0 +1,429 @@
+package com.tencent.qcloud.tuikit.tuicallkit
+
+import android.content.Context
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.interfaces.ITUINotification
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuicore.permission.PermissionRequester
+import com.tencent.qcloud.tuicore.util.SPUtils
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.OfflinePushInfoConfig
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.CallingBellFeature
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.CallingVibratorFeature
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.NotificationFeature
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.DeviceUtils
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.qcloud.tuikit.tuicallkit.utils.UserInfoUtils
+import com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview.IncomingFloatView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview.IncomingNotificationView
+
+class TUICallKitImpl private constructor(context: Context) : TUICallKit(), ITUINotification {
+    private val context: Context = context.applicationContext
+    private var callingBellFeature: CallingBellFeature? = null
+    private var callingVibratorFeature: CallingVibratorFeature? = null
+    private val mainHandler: Handler = Handler(Looper.getMainLooper())
+
+    init {
+        registerCallingEvent()
+    }
+
+    companion object {
+        private const val TAG = "TUICallKitImpl"
+        private const val TAG_VIEW = "IncomingView"
+        private var instance: TUICallKitImpl? = null
+        fun createInstance(context: Context): TUICallKitImpl {
+            if (null == instance) {
+                synchronized(TUICallKitImpl::class.java) {
+                    if (null == instance) {
+                        instance = TUICallKitImpl(context)
+                    }
+                }
+            }
+            return instance!!
+        }
+    }
+
+    override fun setSelfInfo(nickname: String?, avatar: String?, callback: TUICommonDefine.Callback?) {
+        Logger.info(TAG, "setSelfInfo, nickname:$nickname, avatar:$avatar")
+        TUICallEngine.createInstance(context).setSelfInfo(nickname, avatar, callback)
+    }
+
+    override fun call(userId: String, callMediaType: TUICallDefine.MediaType) {
+        Logger.info(TAG, "call userId:$userId, callMediaType:$callMediaType")
+        val params = TUICallDefine.CallParams()
+        params.offlinePushInfo = OfflinePushInfoConfig.createOfflinePushInfo(context)
+        params.timeout = Constants.SIGNALING_MAX_TIME
+        call(userId, callMediaType, params, null)
+    }
+
+    override fun call(
+        userId: String, callMediaType: TUICallDefine.MediaType,
+        params: TUICallDefine.CallParams?, callback: TUICommonDefine.Callback?
+    ) {
+        Logger.info(TAG, "call, userId:$userId, callMediaType:$callMediaType, params:${params?.toString()}")
+        EngineManager.instance.call(userId, callMediaType, params, object :
+            TUICommonDefine.Callback {
+            override fun onSuccess() {
+                callingBellFeature = CallingBellFeature(context)
+                val intent = Intent(context, CallKitActivity::class.java)
+                intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                context.startActivity(intent)
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    override fun groupCall(groupId: String, userIdList: List<String?>?, callMediaType: TUICallDefine.MediaType) {
+        Logger.info(TAG, "groupCall, groupId:$groupId, userIdList:$userIdList, callMediaType:$callMediaType")
+        val params = TUICallDefine.CallParams()
+        params.offlinePushInfo = OfflinePushInfoConfig.createOfflinePushInfo(context)
+        params.timeout = Constants.SIGNALING_MAX_TIME
+        groupCall(groupId, userIdList, callMediaType, params, null)
+    }
+
+    override fun groupCall(
+        groupId: String, userIdList: List<String?>?, mediaType: TUICallDefine.MediaType,
+        params: TUICallDefine.CallParams?, callback: TUICommonDefine.Callback?
+    ) {
+        Logger.info(TAG, "groupCall, groupId:$groupId, userIdList:$userIdList, mediaType: $mediaType, params:$params")
+        EngineManager.instance.groupCall(groupId, userIdList, mediaType, params, object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                callingBellFeature = CallingBellFeature(context)
+                val intent = Intent(context, CallKitActivity::class.java)
+                intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                context.startActivity(intent)
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    override fun calls(
+        userIdList: List<String?>?, mediaType: TUICallDefine.MediaType?, params: TUICallDefine.CallParams?,
+        callback: TUICommonDefine.Callback?
+    ) {
+        EngineManager.instance.calls(userIdList, mediaType, params, object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                callingBellFeature = CallingBellFeature(context)
+                val intent = Intent(context, CallKitActivity::class.java)
+                intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                context.startActivity(intent)
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    override fun join(callId: String?, callback: TUICommonDefine.Callback?) {
+        EngineManager.instance.join(callId, callback)
+    }
+
+    override fun joinInGroupCall(
+        roomId: TUICommonDefine.RoomId?,
+        groupId: String?,
+        mediaType: TUICallDefine.MediaType?
+    ) {
+        Logger.info(TAG, "joinInGroupCall, roomId:$roomId, groupId:$groupId, mediaType:$mediaType")
+        EngineManager.instance.joinInGroupCall(roomId, groupId, mediaType)
+    }
+
+    override fun setCallingBell(filePath: String?) {
+        Logger.info(TAG, "setCallingBell, filePath:$filePath")
+        SPUtils.getInstance(CallingBellFeature.PROFILE_TUICALLKIT)
+            .put(CallingBellFeature.PROFILE_CALL_BELL, filePath)
+    }
+
+    override fun enableMuteMode(enable: Boolean) {
+        Logger.info(TAG, "enableMuteMode, enable:$enable")
+        EngineManager.instance.enableMuteMode(enable)
+    }
+
+    override fun enableFloatWindow(enable: Boolean) {
+        Logger.info(TAG, "enableFloatWindow, enable:$enable")
+        EngineManager.instance.enableFloatWindow(enable)
+    }
+
+    override fun enableVirtualBackground(enable: Boolean) {
+        Logger.info(TAG, "enableVirtualBackground, enable:$enable")
+        TUICallState.instance.showVirtualBackgroundButton = enable
+
+        val data = HashMap<String, Any>()
+        data[Constants.KEY_VIRTUAL_BACKGROUND] = enable
+        EngineManager.instance.reportOnlineLog(data)
+    }
+
+    override fun enableIncomingBanner(enable: Boolean) {
+        Logger.info(TAG, "enableIncomingBanner, enable:$enable")
+        TUICallState.instance.enableIncomingBanner = enable
+    }
+
+    override fun setScreenOrientation(orientation: Int) {
+        Logger.info(TAG, "setScreenOrientation, orientation:$orientation")
+        if (orientation in 0..2) {
+            TUICallState.instance.orientation = Constants.Orientation.values()[orientation]
+        }
+
+        if (orientation == Constants.Orientation.LandScape.ordinal) {
+            val videoEncoderParams = TUICommonDefine.VideoEncoderParams()
+            videoEncoderParams.resolutionMode = TUICommonDefine.VideoEncoderParams.ResolutionMode.Landscape
+            TUICallEngine.createInstance(context).setVideoEncoderParams(videoEncoderParams, null)
+        }
+    }
+
+    fun queryOfflineCall() {
+        Logger.info(TAG, "queryOfflineCall start")
+        if (TUICallDefine.Status.Accept != TUICallState.instance.selfUser.get().callStatus.get()) {
+            val role: TUICallDefine.Role = TUICallState.instance.selfUser.get().callRole.get()
+            val mediaType: TUICallDefine.MediaType = TUICallState.instance.mediaType.get()
+            if (TUICallDefine.Role.None == role || TUICallDefine.MediaType.Unknown == mediaType) {
+                return
+            }
+
+            //The received call has been processed in #onCallReceived
+            if (TUICallDefine.Role.Called == role && PermissionRequester.newInstance(PermissionRequester.BG_START_PERMISSION)
+                    .has()
+            ) {
+                return
+            }
+            PermissionRequest.requestPermissions(context, mediaType, object : PermissionCallback() {
+                override fun onGranted() {
+                    Logger.info(TAG, "queryOfflineCall requestPermissions onGranted")
+                    if (TUICallDefine.Status.None != TUICallState.instance.selfUser.get().callStatus.get()) {
+                        var intent = Intent(context, CallKitActivity::class.java)
+                        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                        context.startActivity(intent)
+                    } else {
+                        TUICallState.instance.clear()
+                    }
+                }
+
+                override fun onDenied() {
+                    if (TUICallDefine.Role.Called == role) {
+                        TUICallEngine.createInstance(context).reject(null)
+                    }
+                }
+            })
+        }
+    }
+
+    private fun registerCallingEvent() {
+        TUICore.registerEvent(
+            TUIConstants.TUILogin.EVENT_LOGIN_STATE_CHANGED,
+            TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGIN_SUCCESS, this
+        )
+        TUICore.registerEvent(
+            TUIConstants.TUILogin.EVENT_LOGIN_STATE_CHANGED,
+            TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGOUT_SUCCESS, this
+        )
+
+        TUICore.registerEvent(Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_ACTIVITY, this)
+        TUICore.registerEvent(Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_INCOMING_VIEW, this)
+    }
+
+    override fun onNotifyEvent(key: String, subKey: String, param: Map<String?, Any>?) {
+        if (TUIConstants.TUILogin.EVENT_LOGIN_STATE_CHANGED == key) {
+            if (TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGOUT_SUCCESS == subKey) {
+                TUICallEngine.createInstance(context).hangup(null)
+                TUICallEngine.destroyInstance()
+                TUICallState.instance.clear()
+            } else if (TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGIN_SUCCESS == subKey) {
+                TUICallEngine.createInstance(context).addObserver(TUICallState.instance.mTUICallObserver)
+                initCallEngine()
+            }
+        }
+
+        if (Constants.EVENT_TUICALLKIT_CHANGED == key) {
+            if (Constants.EVENT_START_ACTIVITY == subKey) {
+                startFullScreenView()
+            } else if (Constants.EVENT_START_INCOMING_VIEW == subKey) {
+                handleNewCall()
+            }
+        }
+    }
+
+    private fun handleNewCall() {
+        mainHandler.post {
+            if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.None) {
+                Logger.warn(TAG_VIEW, "handleNewCall, current status: None, ignore")
+                return@post
+            }
+            callingBellFeature = CallingBellFeature(context)
+            callingVibratorFeature = CallingVibratorFeature(context)
+
+            val hasFloatPermission = PermissionRequester.newInstance(PermissionRequester.FLOAT_PERMISSION).has()
+            val isAppInBackground: Boolean = !DeviceUtils.isAppRunningForeground(context)
+            val hasBgPermission = PermissionRequester.newInstance(PermissionRequester.BG_START_PERMISSION).has()
+            val hasNotificationPermission = PermissionRequest.isNotificationEnabled()
+
+            val isFCMData = isFCMDataNotification()
+
+            Logger.info(
+                TAG_VIEW, "handleNewCall, isAppInBackground: $isAppInBackground, " +
+                        "floatPermission: $hasFloatPermission, " +
+                        "backgroundStartPermission: $hasBgPermission, " +
+                        "notificationPermission: $hasNotificationPermission , " +
+                        "isFCMDataNotification: $isFCMData, " +
+                        "enableIncomingBanner:${TUICallState.instance.enableIncomingBanner}"
+            )
+
+            if (DeviceUtils.isScreenLocked(context)) {
+                Logger.info(TAG_VIEW, "handleNewCall, screen is locked, try to pop up call full screen view")
+                if (isAppInBackground && isFCMData && hasNotificationPermission) {
+                    startSmallScreenView(IncomingNotificationView(context))
+                } else {
+                    startFullScreenView()
+                }
+                return@post
+            }
+
+            if (!TUICallState.instance.enableIncomingBanner) {
+                if (isAppInBackground) {
+                    when {
+                        isFCMData && hasFloatPermission -> startSmallScreenView(IncomingFloatView(context))
+                        isFCMData && hasNotificationPermission -> startSmallScreenView(IncomingNotificationView(context))
+                        hasBgPermission -> startFullScreenView()
+                        else -> {
+                            Logger.warn(TAG_VIEW, "App is in background with no permission")
+                        }
+                    }
+                } else {
+                    startFullScreenView()
+                }
+
+                return@post
+            }
+
+            if (isAppInBackground) {
+                when {
+                    hasFloatPermission -> startSmallScreenView(IncomingFloatView(context))
+                    isFCMData && hasNotificationPermission -> startSmallScreenView(IncomingNotificationView(context))
+                    hasBgPermission -> startFullScreenView()
+                    else -> {
+                        Logger.warn(TAG_VIEW, "App is in background with no permission")
+                    }
+                }
+                return@post
+            }
+
+            when {
+                hasFloatPermission -> startSmallScreenView(IncomingFloatView(context))
+                hasNotificationPermission -> startSmallScreenView(IncomingNotificationView(context))
+                else -> startFullScreenView()
+            }
+        }
+    }
+
+    private fun isFCMDataNotification(): Boolean {
+        return TUICore.getService(TUIConstants.TIMPush.SERVICE_NAME) != null
+                && TUIConfig.getCustomData("pushChannelId") == TUIConstants.DeviceInfo.BRAND_GOOGLE_ELSE
+    }
+
+    private fun startFullScreenView() {
+        Logger.info(TAG_VIEW, "startFullScreenView")
+        mainHandler.post {
+            if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.None) {
+                Logger.warn(TAG_VIEW, "startFullScreenView, current status: None, ignore")
+                return@post
+            }
+            PermissionRequest.requestPermissions(context, TUICallState.instance.mediaType.get(), object :
+                PermissionCallback() {
+                override fun onGranted() {
+                    if (TUICallDefine.Status.None != TUICallState.instance.selfUser.get().callStatus.get()) {
+                        Logger.info(TAG_VIEW, "startFullScreenView requestPermissions onGranted")
+                        val intent = Intent(context, CallKitActivity::class.java)
+                        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                        context.startActivity(intent)
+                    } else {
+                        TUICallState.instance.clear()
+                    }
+                }
+
+                override fun onDenied() {
+                    if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Called) {
+                        TUICallEngine.createInstance(context).reject(null)
+                    }
+                    TUICallState.instance.clear()
+                }
+            })
+        }
+    }
+
+    private fun startSmallScreenView(view: Any) {
+        var caller: User = TUICallState.instance.selfUser.get()
+        for (user in TUICallState.instance.remoteUserList.get()) {
+            if (user.callRole.get() == TUICallDefine.Role.Caller) {
+                caller = user
+                break
+            }
+        }
+
+        val list = ArrayList<String>()
+        caller.id?.let { list.add(it) }
+
+        UserInfoUtils.getUserListInfo(list, object : TUICommonDefine.ValueCallback<List<User>?> {
+            override fun onSuccess(data: List<User>?) {
+                if (data.isNullOrEmpty()) {
+                    return
+                }
+                if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.None) {
+                    Logger.warn(TAG_VIEW, "startSmallScreenView, current status: None, ignore")
+                    return
+                }
+                caller.avatar.set(data[0].avatar.get())
+                caller.nickname.set(data[0].nickname.get())
+
+                if (view is IncomingFloatView) {
+                    view.showIncomingView(caller)
+                } else if (view is IncomingNotificationView) {
+                    view.showNotification(caller)
+                }
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                if (view is IncomingFloatView) {
+                    view.showIncomingView(caller)
+                } else if (view is IncomingNotificationView) {
+                    view.showNotification(caller)
+                }
+            }
+        })
+    }
+
+    private fun initCallEngine() {
+        TUICallEngine.createInstance(context).init(
+            TUILogin.getSdkAppId(), TUILogin.getLoginUser(), TUILogin.getUserSig(), object : TUICommonDefine.Callback {
+                override fun onSuccess() {
+                    TUICallEngine.createInstance(context).addObserver(TUICallState.instance.mTUICallObserver)
+
+                    val notificationFeature = NotificationFeature()
+                    notificationFeature.createCallNotificationChannel(context)
+                }
+
+                override fun onError(errCode: Int, errMsg: String) {}
+            })
+    }
+}

+ 34 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/Constants.kt

@@ -0,0 +1,34 @@
+package com.tencent.qcloud.tuikit.tuicallkit.data
+
+object Constants {
+    const val SIGNALING_MAX_TIME = 30
+    const val MIN_AUDIO_VOLUME = 10
+    const val MAX_USER = 9
+    const val EVENT_TUICALLKIT_CHANGED = "eventTUICallKitChanged"
+    const val EVENT_START_ACTIVITY = "eventStartActivity"
+    const val EVENT_START_INCOMING_VIEW = "eventStartIncomingView"
+
+    const val EVENT_VIEW_STATE_CHANGED = "eventViewStateChanged"
+    const val EVENT_SHOW_FULL_VIEW = "eventShowFullView"
+    const val EVENT_SHOW_FLOAT_VIEW = "eventShowFloatView"
+
+    const val GROUP_ID = "groupId"
+    const val SELECT_MEMBER_LIST = "selectMemberList"
+
+    const val ACCEPT_CALL_ACTION = "accept_call_action"
+    const val REJECT_CALL_ACTION = "reject_call_action"
+
+    const val KEY_VIRTUAL_BACKGROUND = "enablevirtualbackground"
+
+    enum class NetworkQualityHint {
+        None,
+        Local,
+        Remote
+    }
+
+    enum class Orientation {
+        Portrait,
+        LandScape,
+        Auto
+    }
+}

+ 29 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/OfflinePushInfoConfig.kt

@@ -0,0 +1,29 @@
+package com.tencent.qcloud.tuikit.tuicallkit.data
+
+import android.content.Context
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.OfflinePushInfo
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+object OfflinePushInfoConfig {
+    fun createOfflinePushInfo(context: Context): OfflinePushInfo {
+        val pushInfo = OfflinePushInfo()
+        pushInfo.title = if (TUILogin.getNickName().isNullOrEmpty()) TUILogin.getLoginUser() else TUILogin.getNickName()
+        pushInfo.desc = context.getString(R.string.tuicallkit_have_a_new_call)
+        //OPPO must set a ChannelID to receive push messages. If you set it on the console, you don't need set here.
+        //pushInfo.androidOPPOChannelID = "tuikit"
+        pushInfo.isIgnoreIOSBadge = false
+        pushInfo.iosSound = "phone_ringing.mp3"
+        pushInfo.androidSound = "phone_ringing"
+        //VIVO message type: 0-push message, 1-System message(have a higher delivery rate)
+        pushInfo.androidVIVOClassification = 1
+        //FCM channel ID, you need change PrivateConstants.java and set "fcmPushChannelId", If you set it on the console, you don't need set here.
+        //pushInfo.androidFCMChannelID = "fcm_push_channel"
+        //HuaWei message type: https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/message-classification-0000001149358835
+        pushInfo.androidHuaWeiCategory = "IM"
+        //IOS push type: if you want user VoIP, please modify type to TUICallDefine.IOSOfflinePushType.VoIP
+        pushInfo.iosPushType = TUICallDefine.IOSOfflinePushType.APNs
+        return pushInfo
+    }
+}

+ 78 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/data/User.kt

@@ -0,0 +1,78 @@
+package com.tencent.qcloud.tuikit.tuicallkit.data
+
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.trtc.tuikit.common.livedata.LiveData
+import java.util.Objects
+
+class User {
+    var id: String? = null
+    var avatar: LiveData<String> = LiveData()
+    var nickname: LiveData<String> = LiveData()
+
+    var callRole = LiveData<TUICallDefine.Role>()
+    var callStatus: LiveData<TUICallDefine.Status> = LiveData()
+    var audioAvailable: LiveData<Boolean> = LiveData()
+    var videoAvailable: LiveData<Boolean> = LiveData()
+    var playoutVolume: LiveData<Int> = LiveData()
+    var networkQualityReminder: LiveData<Boolean> = LiveData()
+
+    init {
+        avatar.set("")
+        nickname.set("")
+        callStatus.set(TUICallDefine.Status.None)
+        callRole.set(TUICallDefine.Role.None)
+        audioAvailable.set(false)
+        videoAvailable.set(false)
+        playoutVolume.set(0)
+        networkQualityReminder.set(false)
+    }
+
+    fun clear() {
+        avatar.set(null)
+        nickname.set(null)
+        callStatus.set(TUICallDefine.Status.None)
+        callRole.set(TUICallDefine.Role.None)
+        audioAvailable.set(false)
+        videoAvailable.set(false)
+        playoutVolume.set(0)
+        networkQualityReminder.set(false)
+
+        avatar.removeAll()
+        nickname.removeAll()
+        callStatus.removeAll()
+        callRole.removeAll()
+        audioAvailable.removeAll()
+        videoAvailable.removeAll()
+        playoutVolume.removeAll()
+        networkQualityReminder.removeAll()
+    }
+
+    override fun equals(o: Any?): Boolean {
+        if (this === o) {
+            return true
+        }
+        if (o == null || javaClass != o.javaClass) {
+            return false
+        }
+        val model = o as User
+        return id == model.id
+    }
+
+    override fun hashCode(): Int {
+        return Objects.hash(id)
+    }
+
+    override fun toString(): String {
+        return ("User{ "
+                + "id=" + id
+                + ", avatar=" + avatar.get()
+                + ", nickname=" + nickname.get()
+                + ", callRole=" + callRole.get()
+                + ", callStates=" + callStatus.get()
+                + ", audioAvailable=" + audioAvailable.get()
+                + ", videoAvailable=" + videoAvailable.get()
+                + ", playoutVolume=" + playoutVolume.get()
+                + ", networkQualityReminder=" + networkQualityReminder.get()
+                + "}")
+    }
+}

+ 172 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/debug/GenerateTestUserSig.kt

@@ -0,0 +1,172 @@
+package com.tencent.qcloud.tuikit.tuicallkit.debug
+
+import android.text.TextUtils
+import android.util.Base64
+import org.json.JSONException
+import org.json.JSONObject
+import java.nio.charset.Charset
+import java.util.Arrays
+import java.util.zip.Deflater
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+/*
+* Description: Generates UserSig for testing. UserSig is a security signature designed
+*           by Tencent Cloud for its cloud services.
+*           It is calculated based on `SDKAppID`, `UserID`,
+*           and `EXPIRETIME` using the HMAC-SHA256 encryption algorithm.
+*
+* Attention: For the following reasons, do not use the code below in your commercial application.
+*
+*            The code may be able to calculate UserSig correctly, b
+*            ut it is only for quick testing of the SDK’s basic features,
+*            not for commercial applications.
+*            `SECRETKEY` in client code can be easily decompiled and reversed, especially on web.
+*            Once your key is disclosed, attackers will be able to steal your Tencent Cloud traffic.
+*
+*            The correct method is to deploy the `UserSig` calculation code and encryption key on your server
+*            so that your application can request a `UserSig` from your server,
+*            which will calculate it whenever one is needed.
+*            Given that it is more difficult to hack a server than a client application,
+*            server-end calculation can better protect your key.
+*
+* Reference: https://www.tencentcloud.com/document/product/1047/34385
+*/
+object GenerateTestUserSig {
+    /**
+     * Signature validity period, which should not be set too short
+     *
+     *
+     * Unit: Second
+     * Default value: 7 x 24 x 60 x 60 = 604800 (seven days)
+     */
+    private const val EXPIRETIME = 604800
+
+    /**
+     * Calculating UserSig
+     *
+     *
+     * The asymmetric encryption algorithm HMAC-SHA256 is used in the
+     * function to calculate UserSig based on `SDKAppID`, `UserID`, and `EXPIRETIME`.
+     *
+     * @note: For the following reasons, do not use the code below in your commercial application.
+     *
+     *
+     * The code may be able to calculate UserSig correctly,
+     * but it is only for quick testing of the SDK’s basic features,
+     * not for commercial applications.
+     * SECRETKEY in client code can be easily decompiled and reversed, especially on web.
+     * Once your key is disclosed, attackers will be able to steal your Tencent Cloud traffic.
+     *
+     *
+     * The correct method is to deploy the `UserSig` calculation code and encryption key on your server
+     * so that your application can request a `UserSig` from your server,
+     * which will calculate it whenever one is needed.
+     * Given that it is more difficult to hack a server than a client application,
+     * server-end calculation can better protect your key.
+     *
+     *
+     * Documentation: https://www.tencentcloud.com/document/product/1047/34385
+     */
+    @JvmStatic
+    fun genTestUserSig(userId: String, sdkAppId: Int, secretKey: String): String {
+        return genTLSSignature(sdkAppId.toLong(), userId, EXPIRETIME.toLong(), null, secretKey)
+    }
+
+    /**
+     * Generating a TLS Ticket
+     *
+     * @param sdkappId      `appid` of your application
+     * @param userId        User ID
+     * @param expire        Validity period in seconds
+     * @param userbuf       `null` by default
+     * @param priKeyContent Private key required for generating a TLS ticket
+     * @return If an error occurs, an empty string will be returned or exceptions printed.
+     * If the operation succeeds, a valid ticket will be returned.
+     */
+    private fun genTLSSignature(
+        sdkappId: Long, userId: String, expire: Long, userbuf: ByteArray?, priKeyContent: String
+    ): String {
+        if (TextUtils.isEmpty(priKeyContent)) {
+            return ""
+        }
+        val currTime = System.currentTimeMillis() / 1000
+        val sigDoc = JSONObject()
+        try {
+            sigDoc.put("TLS.ver", "2.0")
+            sigDoc.put("TLS.identifier", userId)
+            sigDoc.put("TLS.sdkappid", sdkappId)
+            sigDoc.put("TLS.expire", expire)
+            sigDoc.put("TLS.time", currTime)
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+
+        var base64UserBuf: String? = null
+        if (null != userbuf) {
+            base64UserBuf = Base64.encodeToString(userbuf, Base64.NO_WRAP)
+            try {
+                sigDoc.put("TLS.userbuf", base64UserBuf)
+            } catch (e: JSONException) {
+                e.printStackTrace()
+            }
+        }
+        val sig = hmacsha256(sdkappId, userId, currTime, expire, priKeyContent, base64UserBuf)
+        if (sig.length == 0) {
+            return ""
+        }
+        try {
+            sigDoc.put("TLS.sig", sig)
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+        val compressor = Deflater()
+        compressor.setInput(sigDoc.toString().toByteArray(Charset.forName("UTF-8")))
+        compressor.finish()
+        val compressedBytes = ByteArray(2048)
+        val compressedBytesLength = compressor.deflate(compressedBytes)
+        compressor.end()
+        return String(base64EncodeUrl(Arrays.copyOfRange(compressedBytes, 0, compressedBytesLength)))
+    }
+
+
+    private fun hmacsha256(
+        sdkappid: Long, userId: String, currTime: Long, expire: Long, priKeyContent: String,
+        base64Userbuf: String?
+    ): String {
+        var contentToBeSigned = """
+            TLS.identifier:$userId
+            TLS.sdkappid:$sdkappid
+            TLS.time:$currTime
+            TLS.expire:$expire
+            
+            """.trimIndent()
+        if (null != base64Userbuf) {
+            contentToBeSigned += "TLS.userbuf:$base64Userbuf\n"
+        }
+        try {
+            val byteKey = priKeyContent.toByteArray(charset("UTF-8"))
+            val hmac = Mac.getInstance("HmacSHA256")
+            val keySpec = SecretKeySpec(byteKey, "HmacSHA256")
+            hmac.init(keySpec)
+            val byteSig = hmac.doFinal(contentToBeSigned.toByteArray(charset("UTF-8")))
+            return String(Base64.encode(byteSig, Base64.NO_WRAP))
+        } catch (e: Exception) {
+            e.printStackTrace()
+            return ""
+        }
+    }
+
+    private fun base64EncodeUrl(input: ByteArray): ByteArray {
+        val base64 = String(Base64.encode(input, Base64.NO_WRAP)).toByteArray()
+        for (i in base64.indices) {
+            when (base64[i]) {
+                '+'.code.toByte() -> base64[i] = '*'.code.toByte()
+                '/'.code.toByte() -> base64[i] = '-'.code.toByte()
+                '='.code.toByte() -> base64[i] = '_'.code.toByte()
+                else -> {}
+            }
+        }
+        return base64
+    }
+}

+ 222 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/CallingBellFeature.kt

@@ -0,0 +1,222 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions
+
+import android.content.Context
+import android.content.res.AssetFileDescriptor
+import android.media.AudioAttributes
+import android.media.AudioManager
+import android.media.MediaPlayer
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.text.TextUtils
+import androidx.core.content.ContextCompat
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.liteav.audio.TXAudioEffectManager.AudioMusicParam
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.util.SPUtils
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.DeviceUtils
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+class CallingBellFeature(context: Context) {
+    private val context: Context = context.applicationContext
+    private val mediaPlayer: MediaPlayer
+    private var handler: Handler? = null
+    private var bellResourceId: Int
+    private var bellResourcePath: String
+    private var dialPath: String? = null
+
+
+    init {
+        mediaPlayer = MediaPlayer()
+        bellResourceId = -1
+        bellResourcePath = ""
+
+        addObserver()
+    }
+
+    fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe {
+            when (it) {
+                TUICallDefine.Status.Waiting -> {
+                    if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Caller) {
+                        startDialingMusic()
+                    } else {
+                        if (DeviceUtils.isAppRunningForeground(TUIConfig.getAppContext()) || isFCMData()) {
+                            startRinging()
+                        }
+                    }
+                }
+
+                else -> stopRinging()
+            }
+        }
+    }
+
+    private fun isFCMData(): Boolean {
+        val pushBrandId =
+            TUICore.callService(TUIConstants.TIMPush.SERVICE_NAME, TUIConstants.TIMPush.METHOD_GET_PUSH_BRAND_ID, null)
+        return TUICore.getService(TUIConstants.TIMPush.SERVICE_NAME) != null
+                && pushBrandId == TUIConstants.DeviceInfo.BRAND_GOOGLE_ELSE
+    }
+
+    private fun startRinging() {
+        if (TUICallState.instance.enableMuteMode) {
+            return
+        }
+        val path = SPUtils.getInstance(PROFILE_TUICALLKIT).getString(PROFILE_CALL_BELL, "")
+        if (TextUtils.isEmpty(path)) {
+            start("", R.raw.phone_ringing)
+        } else {
+            start(path, -1)
+        }
+    }
+
+    private fun stopRinging() {
+        stop()
+    }
+
+    private fun startDialingMusic() {
+        if (TextUtils.isEmpty(dialPath)) {
+            dialPath = getBellPath(context, R.raw.phone_dialing, "phone_dialing.mp3")
+        }
+        TUICallEngine.createInstance(context).trtcCloudInstance
+            .audioEffectManager.setMusicPlayoutVolume(AUDIO_DIAL_ID, 100)
+        val param = AudioMusicParam(AUDIO_DIAL_ID, dialPath)
+        param.isShortFile = true
+        TUICallEngine.createInstance(context).trtcCloudInstance.audioEffectManager.startPlayMusic(param)
+    }
+
+    private fun start(resPath: String, resId: Int) {
+        preHandler()
+        if (TextUtils.isEmpty(resPath) && -1 == resId) {
+            return
+        }
+        if (-1 != resId && bellResourceId == resId
+            || !TextUtils.isEmpty(resPath) && TextUtils.equals(bellResourcePath, resPath)
+        ) {
+            return
+        }
+
+        if (!TextUtils.isEmpty(resPath) && isUrl(resPath)) {
+            return
+        }
+
+        var assetFileDescriptor: AssetFileDescriptor? = null
+        if (!TextUtils.isEmpty(resPath) && File(resPath).exists()) {
+            bellResourcePath = resPath
+        } else if (-1 != resId) {
+            bellResourceId = resId
+            assetFileDescriptor = context.resources.openRawResourceFd(resId)
+        }
+
+        val afd = assetFileDescriptor
+        handler?.post(Runnable {
+            if (mediaPlayer.isPlaying) {
+                mediaPlayer.stop()
+            }
+            mediaPlayer.reset()
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+                val attrs = AudioAttributes.Builder()
+                    .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
+                    .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+                    .build()
+                mediaPlayer.setAudioAttributes(attrs)
+            }
+            var mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager?
+            mAudioManager?.mode = AudioManager.MODE_RINGTONE
+            mediaPlayer.setAudioStreamType(AudioManager.STREAM_VOICE_CALL)
+
+            try {
+                if (null != afd) {
+                    mediaPlayer.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
+                } else if (!TextUtils.isEmpty(bellResourcePath)) {
+                    mediaPlayer.setDataSource(bellResourcePath)
+                } else {
+                    return@Runnable
+                }
+                mediaPlayer.isLooping = true
+                mediaPlayer.prepare()
+                mediaPlayer.start()
+            } catch (e: java.lang.Exception) {
+                e.printStackTrace()
+            }
+        })
+    }
+
+    private fun isUrl(url: String): Boolean {
+        return url.startsWith("http://") || url.startsWith("https://")
+    }
+
+    private fun preHandler() {
+        if (null != handler) {
+            return
+        }
+        val thread = HandlerThread("Handler-MediaPlayer")
+        thread.start()
+        handler = Handler(thread.looper)
+    }
+
+    private fun stop() {
+        if (TUICallState.instance?.selfUser?.get()?.callRole?.get() == TUICallDefine.Role.Caller) {
+            TUICallEngine.createInstance(context).trtcCloudInstance
+                .audioEffectManager.stopPlayMusic(AUDIO_DIAL_ID)
+        } else {
+            if (null == handler) {
+                return
+            }
+            if (-1 == bellResourceId && TextUtils.isEmpty(bellResourcePath)) {
+                return
+            }
+            handler!!.post {
+                if (mediaPlayer.isPlaying) {
+                    mediaPlayer.stop()
+                }
+                bellResourceId = -1
+                bellResourcePath = ""
+            }
+        }
+    }
+
+    private fun getBellPath(context: Context, resId: Int, name: String): String? {
+        val savePath = ContextCompat.getExternalFilesDirs(context, null)[0].absolutePath
+        val dir = File(savePath)
+        if (!dir.exists()) {
+            dir.mkdir()
+        }
+        try {
+            val file = File("$savePath/$name")
+            if (file.exists()) {
+                return file.absolutePath
+            }
+            val inputStream = context.resources.openRawResource(resId)
+            val outputStream = FileOutputStream(file)
+            val buffer = ByteArray(2048)
+            var length: Int
+            while (inputStream.read(buffer).also { length = it } > 0) {
+                outputStream.write(buffer, 0, length)
+            }
+            outputStream.flush()
+            outputStream.close()
+            inputStream.close()
+            return file.absolutePath
+        } catch (e: IOException) {
+            e.printStackTrace()
+        }
+        return null
+    }
+
+    companion object {
+        const val PROFILE_TUICALLKIT = "per_profile_tuicallkit"
+        const val PROFILE_CALL_BELL = "per_call_bell"
+        const val PROFILE_MUTE_MODE = "per_mute_mode"
+        const val AUDIO_DIAL_ID = 48
+    }
+}

+ 55 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/CallingVibratorFeature.kt

@@ -0,0 +1,55 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.util.TUIBuild
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.Observer
+
+class CallingVibratorFeature(context: Context) {
+    private val context: Context = context.applicationContext
+    private val vibrator: Vibrator
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        when (it) {
+            TUICallDefine.Status.Waiting -> startVibrating()
+            else -> stopVibrating()
+        }
+    }
+
+    init {
+        if (TUIBuild.getVersionInt() >= Build.VERSION_CODES.S) {
+            val vibratorManager = this.context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+            vibrator = vibratorManager.defaultVibrator
+        } else {
+            vibrator = this.context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+        }
+
+        addObserver()
+    }
+
+    fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    private fun startVibrating() {
+        if (vibrator.hasVibrator()) {
+            val pattern = longArrayOf(0, 500, 1500)
+            if (TUIBuild.getVersionInt() >= Build.VERSION_CODES.O) {
+                val vibrationEffect = VibrationEffect.createWaveform(pattern, 1)
+                vibrator.vibrate(vibrationEffect)
+            } else {
+                vibrator.vibrate(pattern, 1)
+            }
+        }
+    }
+
+    private fun stopVibrating() {
+        vibrator.cancel()
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+    }
+}

+ 38 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/NotificationFeature.kt

@@ -0,0 +1,38 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+class NotificationFeature {
+    private val channelGroupId = "callKitChannelGroupId"
+
+    fun createCallNotificationChannel(context: Context) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+            val channelGroupName = context.getString(R.string.tuicallkit_notification_channel_group_id)
+            val channelGroup = NotificationChannelGroup(channelGroupId, channelGroupName)
+            nm.createNotificationChannelGroup(channelGroup)
+
+            val channelName = context.getString(R.string.tuicallkit_notification_channel_id)
+            val channel =
+                NotificationChannel(CALL_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_HIGH)
+            channel.group = channelGroupId
+            channel.enableLights(true)
+            channel.enableVibration(true)
+            channel.setShowBadge(true)
+            channel.setSound(null, null)
+            channel.setBypassDnd(true)
+            channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+            nm.createNotificationChannel(channel)
+        }
+    }
+
+    companion object {
+        const val CALL_CHANNEL_ID = "CallKitChannelId"
+    }
+}

+ 8 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/GroupMemberInfo.kt

@@ -0,0 +1,8 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.inviteuser;
+
+class GroupMemberInfo {
+    public var userId: String? = ""
+    public var userName: String? = ""
+    public var avatar: String? = ""
+    public var isSelected: Boolean = false
+}

+ 133 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/SelectGroupMemberActivity.kt

@@ -0,0 +1,133 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.inviteuser
+
+import android.graphics.Color
+import android.os.Build
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.View
+import android.view.WindowManager
+import android.widget.Button
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.imsdk.v2.V2TIMGroupMemberFullInfo
+import com.tencent.imsdk.v2.V2TIMGroupMemberInfoResult
+import com.tencent.imsdk.v2.V2TIMManager
+import com.tencent.imsdk.v2.V2TIMValueCallback
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.Observer
+
+class SelectGroupMemberActivity : AppCompatActivity() {
+    private var recyclerUserList: RecyclerView? = null
+    private var groupId: String? = null
+    private val groupMemberList: MutableList<GroupMemberInfo> = ArrayList()
+    private var alreadySelectList: List<String?> = ArrayList()
+    private var adapter: SelectGroupMemberAdapter? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.None) {
+            finish()
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.tuicallkit_activity_group_user)
+        initStatusBar()
+        initView()
+        initData()
+        addObserver()
+    }
+
+    private fun initView() {
+        val toolbar = findViewById<Toolbar>(R.id.toolbar_group)
+        toolbar.navigationIcon?.isAutoMirrored = true
+        toolbar.setNavigationOnClickListener { v: View? -> finish() }
+        val btnOK = findViewById<Button>(R.id.btn_group_ok)
+        btnOK.setOnClickListener { v: View? ->
+            if (adapter != null) {
+                val selectUsers: MutableList<String?> = ArrayList()
+                for (info in groupMemberList) {
+                    if (info != null && !TextUtils.isEmpty(info.userId) && info.isSelected
+                        && !alreadySelectList.contains(info.userId)
+                    ) {
+                        selectUsers.add(info.userId)
+                    }
+                }
+                if (selectUsers.isNotEmpty()) {
+                    EngineManager.instance.inviteUser(selectUsers)
+                }
+            }
+            finish()
+        }
+        recyclerUserList = findViewById(R.id.rv_user_list)
+    }
+
+    private fun initData() {
+        val intent = intent
+        groupId = intent.getStringExtra(Constants.GROUP_ID)
+        alreadySelectList = ArrayList(intent.getStringArrayListExtra(Constants.SELECT_MEMBER_LIST))
+        adapter = SelectGroupMemberAdapter()
+        recyclerUserList!!.layoutManager = LinearLayoutManager(applicationContext)
+        recyclerUserList!!.adapter = adapter
+        updateGroupUserList()
+    }
+
+    private fun updateGroupUserList() {
+        val filter = V2TIMGroupMemberFullInfo.V2TIM_GROUP_MEMBER_FILTER_ALL
+        V2TIMManager.getGroupManager().getGroupMemberList(groupId, filter, 0,
+            object : V2TIMValueCallback<V2TIMGroupMemberInfoResult?> {
+                override fun onError(errorCode: Int, errorMsg: String) {}
+                override fun onSuccess(v2TIMGroupMemberInfoResult: V2TIMGroupMemberInfoResult?) {
+                    val results = v2TIMGroupMemberInfoResult?.memberInfoList
+                    groupMemberList.clear()
+                    if (results == null) {
+                        return
+                    }
+                    for (info in results) {
+                        val userInfo = GroupMemberInfo()
+                        userInfo.userId = info.userID
+                        userInfo.avatar = info.faceUrl
+                        userInfo.userName = info.nickName
+                        userInfo.isSelected = alreadySelectList?.contains(userInfo.userId) == true
+                        groupMemberList.add(userInfo)
+                    }
+                    if (adapter != null) {
+                        adapter!!.setDataSource(groupMemberList)
+                        adapter!!.notifyDataSetChanged()
+                    }
+                }
+            })
+    }
+
+    private fun initStatusBar() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            val window = window
+            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+            window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+                    or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
+            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+            window.statusBarColor = Color.TRANSPARENT
+        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+            window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+        }
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        removeObserver()
+    }
+}

+ 69 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/inviteuser/SelectGroupMemberAdapter.kt

@@ -0,0 +1,69 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.inviteuser
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.CheckBox
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.inviteuser.SelectGroupMemberAdapter.GroupMemberViewHolder
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader.loadImage
+
+class SelectGroupMemberAdapter : RecyclerView.Adapter<GroupMemberViewHolder>() {
+    private var mContext: Context? = null
+    private var mGroupMemberList: List<GroupMemberInfo> = ArrayList()
+    fun setDataSource(userList: List<GroupMemberInfo>) {
+        mGroupMemberList = userList
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupMemberViewHolder {
+        mContext = parent.context
+        val view = LayoutInflater.from(mContext).inflate(R.layout.tuicallkit_list_item_group_user, parent, false)
+        return GroupMemberViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: GroupMemberViewHolder, position: Int) {
+        val userInfo = mGroupMemberList[position]
+        holder.itemView.setOnClickListener { v: View? ->
+            holder.mCheckBox.isChecked = !holder.mCheckBox.isChecked
+            userInfo.isSelected = holder.mCheckBox.isChecked
+        }
+        holder.layoutView(userInfo)
+    }
+
+    override fun getItemCount(): Int {
+        return mGroupMemberList.size
+    }
+
+    inner class GroupMemberViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        public val mImageAvatar: ImageView
+        public val mTextName: TextView
+        public val mTextHint: TextView
+        public val mCheckBox: CheckBox
+        fun layoutView(userInfo: GroupMemberInfo?) {
+            if (userInfo == null || TextUtils.isEmpty(userInfo.userId)) {
+                return
+            }
+            itemView.isEnabled = !userInfo.isSelected
+            mCheckBox.isEnabled = !userInfo.isSelected
+            mCheckBox.isChecked = userInfo.isSelected
+            mCheckBox.isSelected = userInfo.isSelected
+            mTextName.text = if (TextUtils.isEmpty(userInfo.userName)) userInfo.userId else userInfo.userName
+            mTextHint.visibility =
+                if (userInfo.userId == TUILogin.getLoginUser()) View.VISIBLE else View.GONE
+            loadImage(mContext, mImageAvatar, userInfo.avatar, R.drawable.tuicallkit_ic_avatar)
+        }
+
+        init {
+            mCheckBox = itemView.findViewById(R.id.group_user_check_box)
+            mImageAvatar = itemView.findViewById(R.id.group_user_avatar)
+            mTextName = itemView.findViewById(R.id.group_user_name)
+            mTextHint = itemView.findViewById(R.id.group_user_hint)
+        }
+    }
+}

+ 128 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/joiningroupcall/JoinInGroupCallView.kt

@@ -0,0 +1,128 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.joiningroupcall
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.MediaType
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.ValueCallback
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.TUICallKit
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.UserInfoUtils
+
+class JoinInGroupCallView(context: Context) : FrameLayout(context) {
+    private var layoutExpand: ConstraintLayout? = null
+    private var layoutUserListView: LinearLayout? = null
+    private var imageIconExpand: ImageView? = null
+    private var textUserHint: TextView? = null
+    private var btnJoinCall: Button? = null
+
+    private var appContext = context.applicationContext
+    private var isViewExpand: Boolean = false
+
+    init {
+        initView()
+    }
+
+    private fun initView() {
+        val callView = LayoutInflater.from(appContext).inflate(R.layout.tuicallkit_join_group_call_expand_view, this)
+
+        textUserHint = callView?.findViewById(R.id.tv_user_hint)
+        imageIconExpand = callView?.findViewById(R.id.img_ic_expand)
+        btnJoinCall = callView?.findViewById(R.id.btn_join_call)
+        layoutUserListView = callView?.findViewById(R.id.ll_layout_avatar)
+        layoutExpand = callView?.findViewById(R.id.cl_expand_view)
+
+        imageIconExpand?.setBackgroundResource(R.drawable.tuicallkit_ic_join_group_expand)
+        layoutExpand?.visibility = LinearLayout.GONE
+    }
+
+    fun updateView(groupId: String?, roomId: TUICommonDefine.RoomId, mediaType: MediaType, userList: List<String>?) {
+        Logger.info(TAG,"updateView groupId: $groupId, roomId: $roomId, mediaType: $mediaType, userList: $userList")
+
+        btnJoinCall?.text = context.resources.getString(R.string.tuicallkit_join_group_call)
+
+        if (userList.isNullOrEmpty()) {
+            return
+        }
+        refreshUserAvatarView(userList)
+
+        val hint = appContext.resources.getString(R.string.tuicallkit_join_group_call_users)
+        val type = if (mediaType == MediaType.Video) {
+            R.string.tuicallkit_video_call
+        } else {
+            R.string.tuicallkit_audio_call
+        }
+        textUserHint?.text = String.format(hint, userList.size, appContext.resources.getString(type))
+
+        btnJoinCall?.setOnClickListener {
+            TUICallKit.createInstance(appContext).joinInGroupCall(roomId, groupId, mediaType)
+        }
+
+        imageIconExpand?.setOnClickListener {
+            displayExpandView()
+        }
+    }
+
+    private fun refreshUserAvatarView(list: List<String>) {
+        UserInfoUtils.getUserListInfo(list, object : ValueCallback<List<User>?> {
+            override fun onSuccess(data: List<User>?) {
+                data?.let { setAvatar(it) }
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                setAvatar(list)
+            }
+        })
+    }
+
+    private fun setAvatar(userList: List<Any>) {
+        layoutUserListView?.removeAllViews()
+
+        val width = ScreenUtil.dip2px(50f)
+        val startMargin = ScreenUtil.dip2px(12f)
+
+        for ((index, user) in userList.withIndex()) {
+            val imageView = ImageFilterView(appContext)
+            val layoutParams = LinearLayout.LayoutParams(width, width)
+            if (index != 0) {
+                layoutParams.marginStart = startMargin
+            }
+            imageView.round = 12f
+            imageView.scaleType = ImageView.ScaleType.CENTER_CROP
+            imageView.layoutParams = layoutParams
+            if (user is User) {
+                ImageLoader.loadImage(appContext, imageView, user.avatar.get(), R.drawable.tuicallkit_ic_avatar)
+            } else {
+                ImageLoader.loadImage(appContext, imageView, R.drawable.tuicallkit_ic_avatar)
+            }
+            layoutUserListView?.addView(imageView)
+        }
+    }
+
+    private fun displayExpandView() {
+        if (isViewExpand) {
+            layoutExpand?.visibility = LinearLayout.GONE
+            imageIconExpand?.setBackgroundResource(R.drawable.tuicallkit_ic_join_group_expand)
+            isViewExpand = false
+        } else {
+            layoutExpand?.visibility = LinearLayout.VISIBLE
+            imageIconExpand?.setBackgroundResource(R.drawable.tuicallkit_ic_join_group_compress)
+            isViewExpand = true
+        }
+    }
+
+    companion object {
+        private const val TAG = "JoinInGroupCall"
+    }
+}

+ 169 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/joiningroupcall/JoinInGroupCallViewModel.kt

@@ -0,0 +1,169 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.joiningroupcall
+
+import android.content.Context
+import android.view.View
+import com.google.gson.Gson
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.imsdk.v2.V2TIMGroupListener
+import com.tencent.imsdk.v2.V2TIMManager
+import com.tencent.imsdk.v2.V2TIMValueCallback
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.trtc.tuikit.common.livedata.Observer
+
+class JoinInGroupCallViewModel(context: Context) {
+    private val appContext: Context
+    private var callView: JoinInGroupCallView? = null
+    private var currentGroupId: String? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.None) {
+            currentGroupId?.let { it1 -> getGroupAttributes(it1) }
+        }
+    }
+
+    private val groupListener: V2TIMGroupListener = object : V2TIMGroupListener() {
+        override fun onGroupAttributeChanged(groupID: String?, groupAttributeMap: MutableMap<String?, String>?) {
+            if (groupID.isNullOrEmpty() || currentGroupId != groupID) {
+                Logger.warn(TAG, "onGroupAttributes, not same group(current:$currentGroupId, $groupID)}, ignore")
+                return
+            }
+
+            parseGroupAttributes(groupID, groupAttributeMap)
+        }
+    }
+
+    init {
+        appContext = context.applicationContext
+        V2TIMManager.getInstance().addGroupListener(groupListener)
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    fun setJoinInGroupCallView(joinInGroupCallView: JoinInGroupCallView) {
+        callView = joinInGroupCallView
+        callView?.visibility = View.GONE
+    }
+
+    fun getGroupAttributes(groupId: String) {
+        Logger.info(TAG, "getGroupAttributes, groupId: $groupId")
+        currentGroupId = groupId
+
+        V2TIMManager.getGroupManager().getGroupAttributes(groupId, listOf(KEY_GROUP_ATTRIBUTE),
+            object : V2TIMValueCallback<Map<String?, String?>?> {
+                override fun onSuccess(map: Map<String?, String?>?) {
+
+                    parseGroupAttributes(groupId, map)
+                }
+
+                override fun onError(code: Int, desc: String) {
+                    Logger.error(TAG, "getGroupAttributes failed, errorCode: $code , errorMsg: $desc")
+                }
+            })
+    }
+
+    private fun parseGroupAttributes(groupId: String, map: Map<String?, String?>?) {
+        if (TUICallState.instance.selfUser.get().callStatus.get() != TUICallDefine.Status.None) {
+            removeCallView()
+            Logger.warn(TAG, "parseGroupAttributes, user is in the call, ignore")
+            return
+        }
+
+        if (map == null || map[KEY_GROUP_ATTRIBUTE].isNullOrEmpty()) {
+            Logger.warn(TAG, "parseGroupAttributes is empty, map: $map")
+            removeCallView()
+            return
+        }
+
+        Logger.info(TAG, "parseGroupAttributes, groupId: $groupId, map: $map")
+
+        val data: String? = map[KEY_GROUP_ATTRIBUTE]
+        val extraMap: Map<String, Any> = Gson().fromJson<Map<String, Any>>(data, Map::class.java)
+
+        val businessType = extraMap[KEY_BUSINESS_TYPE] as? String
+        if (businessType.isNullOrEmpty() || businessType != VALUE_BUSINESS_TYPE) {
+            Logger.warn(TAG, "no user in the call")
+            removeCallView()
+            return
+        }
+
+        val userList = parseUserList(extraMap)
+        if (userList.isEmpty() || userList.contains(TUILogin.getLoginUser()) || userList.size <= 1) {
+            Logger.warn(TAG, "userList: $userList, loginUser:${TUILogin.getLoginUser()}")
+            removeCallView()
+            return
+        }
+
+        val mediaType = if (extraMap[KEY_CALL_MEDIA_TYPE] == VALUE_MEDIA_TYPE_VIDEO) {
+            TUICallDefine.MediaType.Video
+        } else {
+            TUICallDefine.MediaType.Audio
+        }
+
+        val roomId = parseRoomId(extraMap)
+        callView?.updateView(groupId, roomId, mediaType, userList)
+        callView?.visibility = View.VISIBLE
+    }
+
+    private fun parseRoomId(extraMap: Map<String, Any>): TUICommonDefine.RoomId {
+        val roomIdType = (extraMap[KEY_ROOM_ID_TYPE] as? Double)?.toInt()
+        val strRoomId = extraMap[KEY_ROOM_ID] as? String
+
+        val roomId = TUICommonDefine.RoomId()
+        if (roomIdType == VALUE_ROOM_ID_TYPE_STRING) {
+            roomId.strRoomId = strRoomId
+            return roomId
+        }
+
+        val intRoomId = strRoomId?.toIntOrNull()
+        if (intRoomId != null && intRoomId != 0) {
+            roomId.intRoomId = intRoomId
+        } else {
+            roomId.strRoomId = strRoomId
+        }
+        return roomId
+    }
+
+    private fun parseUserList(extraMap: Map<String, Any>): List<String> {
+        val userIds = extraMap[KEY_USER_LIST] as? List<Map<String, String>>
+        if (userIds == null) {
+            Logger.warn(TAG, "parseUserList, userList is empty, ignore")
+            return ArrayList<String>()
+        }
+        val list = ArrayList<String>()
+
+        for (temp in userIds) {
+            val userId = temp[KEY_USER_ID]
+            if (userId != null) {
+                list.add(userId)
+            }
+        }
+        return list
+    }
+
+    private fun removeCallView() {
+        callView?.visibility = View.GONE
+    }
+
+    companion object {
+        private const val TAG = "JoinInGroupCall"
+
+        //Do not change these items
+        private const val KEY_GROUP_ATTRIBUTE = "inner_attr_kit_info"
+        private const val KEY_BUSINESS_TYPE = "business_type"
+        private const val KEY_ROOM_ID_TYPE = "room_id_type"
+        private const val KEY_ROOM_ID = "room_id"
+        private const val KEY_GROUP_ID = "group_id"
+        private const val KEY_CALL_MEDIA_TYPE = "call_media_type"
+        private const val KEY_USER_LIST = "user_list"
+        private const val KEY_USER_ID = "userid"
+
+        private const val VALUE_BUSINESS_TYPE = "callkit"
+        private const val VALUE_MEDIA_TYPE_AUDIO = "audio"
+        private const val VALUE_MEDIA_TYPE_VIDEO = "video"
+        private const val VALUE_ROOM_ID_TYPE_HISTORY = 0
+        private const val VALUE_ROOM_ID_TYPE_INT = 1
+        private const val VALUE_ROOM_ID_TYPE_STRING = 2
+    }
+}

+ 298 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsFragment.kt

@@ -0,0 +1,298 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.tabs.TabLayout
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallRecords
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.RecentCallsFilter
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUIConstants.TUICalling.ObjectFactory.RecentCalls
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.util.ToastUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.TUICallKit.Companion.createInstance
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.recents.interfaces.ICallRecordItemListener
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.SlideRecyclerView
+
+class RecentCallsFragment : Fragment {
+    private lateinit var rootView: View
+    private lateinit var buttonEdit: Button
+    private lateinit var buttonStartCall: Button
+    private lateinit var buttonEditDone: Button
+    private lateinit var buttonClear: Button
+    private lateinit var layoutTab: TabLayout
+    private lateinit var recyclerRecent: SlideRecyclerView
+    private lateinit var layoutTitle: ConstraintLayout
+    private lateinit var listAdapter: RecentCallsListAdapter
+    private lateinit var viewModel: RecentCallsViewModel
+    private var bottomSheetDialog: BottomSheetDialog? = null
+    private var chatViewStyle = RecentCalls.UI_STYLE_MINIMALIST
+    private var type = TYPE_ALL
+
+    private var needCloseMultiMode = false
+
+    constructor() {}
+    constructor(style: String) {
+        chatViewStyle = style
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        rootView = inflater.inflate(R.layout.tuicallkit_record_fragment_main, container, false)
+        initView()
+        initData()
+        initListener()
+        return rootView
+    }
+
+    override fun onResume() {
+        super.onResume()
+        refreshData()
+    }
+
+    private fun initView() {
+        buttonEdit = rootView.findViewById(R.id.btn_call_edit)
+        buttonStartCall = rootView.findViewById(R.id.btn_start_call)
+        buttonEditDone = rootView.findViewById(R.id.btn_edit_done)
+        buttonClear = rootView.findViewById(R.id.btn_clear)
+        layoutTab = rootView.findViewById(R.id.tab_layout)
+        recyclerRecent = rootView.findViewById(R.id.recycle_view_list)
+        layoutTitle = rootView.findViewById(R.id.cl_record_title)
+        if (RecentCalls.UI_STYLE_MINIMALIST == chatViewStyle) {
+            layoutTitle?.setBackgroundColor(resources.getColor(R.color.tuicallkit_color_white))
+        }
+    }
+
+    private fun initData() {
+        listAdapter = RecentCallsListAdapter()
+        listAdapter.setHasStableIds(true)
+        recyclerRecent.layoutManager = LinearLayoutManager(context)
+        recyclerRecent.adapter = listAdapter
+        setAdapterListener()
+        viewModel = ViewModelProvider(requireActivity()).get(RecentCallsViewModel::class.java)
+        viewModel.callHistoryList.observe(requireActivity()) { recordList: List<CallRecords>? ->
+            if (listAdapter != null && TYPE_ALL == type) {
+                listAdapter.onDataSourceChanged(recordList)
+            }
+        }
+        viewModel.callMissedList.observe(requireActivity()) { recordList: List<CallRecords>? ->
+            if (listAdapter != null && TYPE_MISS == type) {
+                listAdapter.onDataSourceChanged(recordList)
+            }
+        }
+        if (viewModel != null) {
+            viewModel.queryRecentCalls(filter)
+        }
+    }
+
+    private val filter: RecentCallsFilter
+        private get() {
+            val filter = RecentCallsFilter()
+            if (TYPE_MISS == type) {
+                filter.result = CallRecords.Result.Missed
+            }
+            return filter
+        }
+
+    private fun initListener() {
+        buttonEdit.setOnClickListener { v: View? ->
+            startMultiSelect()
+            updateTabViews(true)
+        }
+        buttonStartCall.setOnClickListener { v: View? ->
+            TUICore.startActivity(
+                "StartC2CChatMinimalistActivity",
+                null
+            )
+        }
+        buttonEditDone.setOnClickListener { v: View? ->
+            needCloseMultiMode = true
+            stopMultiSelect()
+            updateTabViews(false)
+        }
+        buttonClear.setOnClickListener { v: View? -> showDeleteHistoryDialog() }
+        layoutTab.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+            override fun onTabSelected(tab: TabLayout.Tab) {
+                type = if (tab.position == 1) TYPE_MISS else TYPE_ALL
+                updateTabViews(false)
+                needCloseMultiMode = true
+                stopMultiSelect()
+                refreshData()
+            }
+
+            override fun onTabUnselected(tab: TabLayout.Tab) {}
+            override fun onTabReselected(tab: TabLayout.Tab) {}
+        })
+    }
+
+    private fun refreshData() {
+        if (viewModel != null) {
+            viewModel.queryRecentCalls(filter)
+        }
+    }
+
+    private fun updateTabViews(isEditable: Boolean) {
+        if (isEditable) {
+            buttonEdit.visibility = View.GONE
+            buttonStartCall.visibility = View.GONE
+            buttonEditDone.visibility = View.VISIBLE
+            buttonClear.visibility = View.VISIBLE
+        } else {
+            buttonEdit.visibility = View.VISIBLE
+            buttonStartCall.visibility = View.GONE
+            buttonEditDone.visibility = View.GONE
+            buttonClear.visibility = View.GONE
+        }
+    }
+
+    private fun setAdapterListener() {
+        listAdapter.setOnCallRecordItemListener(object : ICallRecordItemListener {
+            override fun onItemClick(view: View?, viewType: Int, callRecords: CallRecords?) {
+                if (callRecords == null) {
+                    return
+                }
+                if (listAdapter.isMultiSelectMode) {
+                    return
+                }
+                if (callRecords.scene == TUICallDefine.Scene.GROUP_CALL) {
+                    startGroupInfoActivity(callRecords)
+                    ToastUtil.toastLongMessage(getString(R.string.tuicallkit_group_recall_unsupport))
+                    return
+                }
+                if (TUICallDefine.Role.Caller == callRecords.role) {
+                    createInstance(context!!).call(callRecords.inviteList[0], callRecords.mediaType)
+                } else {
+                    createInstance(context!!).call(callRecords.inviter, callRecords.mediaType)
+                }
+            }
+
+            override fun onItemDeleteClick(view: View?, viewType: Int, callRecords: CallRecords?) {
+                if (callRecords == null) {
+                    return
+                }
+                val list: MutableList<CallRecords> = ArrayList()
+                list.add(callRecords)
+                deleteRecordCalls(list)
+            }
+
+            override fun onDetailViewClick(view: View?, records: CallRecords?) {
+                if (records == null) {
+                    return
+                }
+                if (TUICallDefine.Scene.SINGLE_CALL == records.scene) {
+                    startFriendProfileActivity(records)
+                } else if (TUICallDefine.Scene.GROUP_CALL == records.scene) {
+                    startGroupInfoActivity(records)
+                }
+            }
+        })
+    }
+
+    private fun startFriendProfileActivity(records: CallRecords) {
+        val bundle = Bundle()
+        if (TUICallDefine.Role.Caller == records.role) {
+            bundle.putString(TUIConstants.TUIChat.CHAT_ID, records.inviteList[0])
+        } else {
+            bundle.putString(TUIConstants.TUIChat.CHAT_ID, records.inviter)
+        }
+        var activityName = "FriendProfileActivity"
+        if (RecentCalls.UI_STYLE_MINIMALIST == chatViewStyle) {
+            activityName = "FriendProfileMinimalistActivity"
+        }
+        TUICore.startActivity(activityName, bundle)
+    }
+
+    private fun startGroupInfoActivity(records: CallRecords) {
+        val bundle = Bundle()
+        bundle.putString("group_id", records.groupId)
+        var activityName = "GroupInfoActivity"
+        if (RecentCalls.UI_STYLE_MINIMALIST == chatViewStyle) {
+            activityName = "GroupInfoMinimalistActivity"
+        }
+        TUICore.startActivity(context, activityName, bundle)
+    }
+
+    private fun startMultiSelect() {
+        val adapter = recyclerRecent.adapter as RecentCallsListAdapter?
+        if (adapter != null) {
+            adapter.setShowMultiSelectCheckBox(true)
+            adapter.notifyDataSetChanged()
+        }
+        recyclerRecent.disableRecyclerViewSlide(true)
+        recyclerRecent.closeMenu()
+    }
+
+    private fun stopMultiSelect() {
+        val adapter = recyclerRecent.adapter as RecentCallsListAdapter?
+        if (adapter != null) {
+            if (needCloseMultiMode) {
+                adapter.setShowMultiSelectCheckBox(false)
+            }
+            adapter.notifyDataSetChanged()
+        }
+        if (needCloseMultiMode) {
+            recyclerRecent.disableRecyclerViewSlide(false)
+        }
+        recyclerRecent.closeMenu()
+    }
+
+    private fun deleteRecordCalls(selectItem: List<CallRecords>) {
+        if (viewModel != null) {
+            viewModel.deleteRecordCalls(selectItem)
+        }
+        needCloseMultiMode = !listAdapter.isMultiSelectMode
+        stopMultiSelect()
+    }
+
+    private fun clearRecentCalls() {
+        var selectedItems: List<CallRecords?>? = ArrayList()
+        if (listAdapter != null) {
+            selectedItems = listAdapter.selectedItem
+        }
+        if (selectedItems == null) {
+            return
+        }
+        val recordList: MutableList<CallRecords> = ArrayList()
+        for (records in selectedItems) {
+            if (records != null && !TextUtils.isEmpty(records.callId)) {
+                recordList.add(records)
+            }
+        }
+        if (viewModel != null) {
+            viewModel.deleteRecordCalls(recordList)
+        }
+    }
+
+    private fun showDeleteHistoryDialog() {
+        if (bottomSheetDialog == null) {
+            bottomSheetDialog = BottomSheetDialog(requireContext(), R.style.TUICallBottomSelectSheet)
+        }
+        bottomSheetDialog?.setContentView(R.layout.tuicallkit_record_dialog)
+        bottomSheetDialog?.setCanceledOnTouchOutside(false)
+        val textPositive = bottomSheetDialog?.findViewById<TextView>(R.id.tv_clear_call_history)
+        val textCancel = bottomSheetDialog?.findViewById<TextView>(R.id.tv_clear_cancel)
+        textPositive?.setOnClickListener { v: View? ->
+            clearRecentCalls()
+            bottomSheetDialog?.dismiss()
+            needCloseMultiMode = true
+            stopMultiSelect()
+        }
+        textCancel?.setOnClickListener { v: View? -> bottomSheetDialog?.dismiss() }
+        bottomSheetDialog?.show()
+    }
+
+    companion object {
+        const val TYPE_ALL = "AllCall"
+        const val TYPE_MISS = "MissedCall"
+    }
+}

+ 116 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsItemHolder.kt

@@ -0,0 +1,116 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.View
+import android.widget.CheckBox
+import android.widget.ImageView
+import android.widget.RelativeLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallRecords
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.ValueCallback
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.util.DateTimeUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.utils.UserInfoUtils
+import java.util.Date
+
+class RecentCallsItemHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+    public lateinit var callIconView: RecordsIconView
+    public lateinit var textUserTitle: TextView
+    public lateinit var imageMediaType: ImageView
+    public lateinit var textCallStatus: TextView
+    public lateinit var textCallTime: TextView
+    public lateinit var imageDetails: ImageView
+    public lateinit var layoutDelete: RelativeLayout
+    public lateinit var checkBoxSelectCall: CheckBox
+    public lateinit var layoutView: ConstraintLayout
+
+    init {
+        initView()
+    }
+
+    private fun initView() {
+        layoutDelete = itemView.findViewById(R.id.ll_call_delete)
+        checkBoxSelectCall = itemView.findViewById(R.id.cb_call_select)
+        callIconView = itemView.findViewById(R.id.call_icon)
+        textUserTitle = itemView.findViewById(R.id.tv_call_user_id)
+        imageMediaType = itemView.findViewById(R.id.call_media_type)
+        textCallStatus = itemView.findViewById(R.id.tv_call_status)
+        textCallTime = itemView.findViewById(R.id.tv_call_time)
+        imageDetails = itemView.findViewById(R.id.img_call_details)
+        layoutView = itemView.findViewById(R.id.cl_info_layout)
+    }
+
+    fun layoutViews(context: Context, records: CallRecords?, position: Int) {
+        if (records == null) {
+            return
+        }
+        val colorId = if (CallRecords.Result.Missed == records.result) {
+            R.color.tuicallkit_record_text_red
+        } else {
+            R.color.tuicallkit_color_black
+        }
+        textUserTitle.setTextColor(context.resources.getColor(colorId))
+        val imageId = if (TUICallDefine.MediaType.Video == records.mediaType) {
+            R.drawable.tuicallkit_record_ic_video_call
+        } else {
+            R.drawable.tuicallkit_record_ic_audio_call
+        }
+        imageMediaType.setImageDrawable(context.resources.getDrawable(imageId))
+        var resultMsg = context.getString(R.string.tuicallkit_record_result_unknown)
+        if (CallRecords.Result.Missed == records.result) {
+            resultMsg = context.getString(R.string.tuicallkit_record_result_missed)
+        } else if (CallRecords.Result.Incoming == records.result) {
+            resultMsg = context.getString(R.string.tuicallkit_record_result_incoming)
+        } else if (CallRecords.Result.Outgoing == records.result) {
+            resultMsg = context.getString(R.string.tuicallkit_record_result_outgoing)
+        }
+        textCallStatus.text = resultMsg
+        textCallTime.text = DateTimeUtil.getTimeFormatText(Date(records.beginTime))
+        val list: MutableList<String> = ArrayList()
+        if (records.inviteList != null) {
+            list.addAll(records.inviteList)
+        }
+        list.add(records.inviter.trim { it <= ' ' })
+        list.remove(TUILogin.getLoginUser())
+        callIconView.tag = list
+        UserInfoUtils.getUserListInfo(list, object : ValueCallback<List<User>?> {
+            override fun onSuccess(userFullInfoList: List<User>?) {
+                if (userFullInfoList.isNullOrEmpty()) {
+                    return
+                }
+                val avatarList: MutableList<Any?> = ArrayList()
+                val newUserList: MutableList<String> = ArrayList()
+                val nameList: MutableList<String> = ArrayList()
+                for (i in userFullInfoList.indices) {
+                    avatarList.add(userFullInfoList[i].avatar)
+                    newUserList.add(userFullInfoList[i].id!!)
+                    nameList.add(userFullInfoList[i].nickname.get())
+                }
+                if (!TextUtils.isEmpty(records.groupId)) {
+                    avatarList.add(TUILogin.getFaceUrl())
+                }
+                val oldUserList: List<String> = ArrayList(
+                    callIconView.tag as List<String>
+                )
+                if (oldUserList.size == newUserList.size && oldUserList.containsAll(newUserList)) {
+                    callIconView.setImageId(records.callId)
+                    callIconView.displayImage(avatarList).load(records.callId)
+                    textUserTitle.text = nameList.toString().replace("[\\[\\]]".toRegex(), "")
+                }
+            }
+
+            override fun onError(code: Int, desc: String?) {
+                val list: MutableList<Any?> = ArrayList()
+                list.add(TUILogin.getFaceUrl())
+                callIconView.displayImage(list).load(records.callId)
+                textUserTitle.text = TUILogin.getNickName()
+            }
+        })
+    }
+}

+ 192 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsListAdapter.kt

@@ -0,0 +1,192 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallRecords
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.recents.interfaces.ICallRecordItemListener
+
+class RecentCallsListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
+    private lateinit var context: Context
+    private val dataSource: MutableList<CallRecords> = ArrayList()
+    private var itemListener: ICallRecordItemListener? = null
+    private val selectedPositions = HashMap<String, Boolean>()
+    var isMultiSelectMode = false
+        private set
+
+    fun setOnCallRecordItemListener(listener: ICallRecordItemListener?) {
+        itemListener = listener
+    }
+
+    fun onDataSourceChanged(dataSource: List<CallRecords>?) {
+        dataSource?.let {
+            this.dataSource.clear()
+            this.dataSource.addAll(it)
+            notifyDataSetChanged()
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+        context = parent.context.applicationContext
+        val inflater = LayoutInflater.from(parent.context)
+        return if (viewType == ITEM_TYPE_HEADER) {
+            val view =
+                inflater.inflate(R.layout.tuicallkit_item_head_view, parent, false)
+            HeaderViewHolder(view)
+        } else {
+            val view = inflater.inflate(
+                R.layout.tuicallkit_layout_call_list_item,
+                parent,
+                false
+            )
+            RecentCallsItemHolder(view)
+        }
+    }
+
+    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+        if (holder is RecentCallsItemHolder) {
+            val viewHolder = holder
+            viewHolder.layoutView.setOnClickListener { v: View? ->
+                val curPos = viewHolder.bindingAdapterPosition
+                itemListener?.onItemClick(viewHolder.itemView, getItemViewType(curPos), getItem(curPos))
+            }
+            viewHolder.imageDetails.setOnClickListener { view: View? ->
+                itemListener?.onDetailViewClick(view, getItem(viewHolder.bindingAdapterPosition))
+            }
+
+            if (!viewHolder.layoutDelete.hasOnClickListeners()) {
+                viewHolder.layoutDelete.setOnClickListener { view: View? ->
+                    val curPos = viewHolder.bindingAdapterPosition
+                    val record = getItem(curPos)
+                    if (record == null || TextUtils.isEmpty(record.callId)) {
+                        return@setOnClickListener
+                    }
+                    setItemChecked(record.callId, true)
+                    itemListener?.onItemDeleteClick(view, getItemViewType(curPos), record)
+                }
+            }
+            holder.checkBoxSelectCall.setOnClickListener {
+                holder.itemView.scrollX = if (isRTL) -holder.layoutDelete.width else holder.layoutDelete.width
+            }
+            viewHolder.layoutViews(context, getItem(position), position)
+            setCheckBoxStatus(viewHolder)
+        }
+    }
+
+    override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
+        if (holder is RecentCallsItemHolder) {
+            holder.callIconView.clear()
+        }
+    }
+
+    private fun getItem(position: Int): CallRecords? {
+        if (dataSource.isNullOrEmpty()) {
+            return null
+        }
+        val dataPosition = position - HEADER_COUNT
+        return if (dataPosition < dataSource.size && dataPosition >= 0) {
+            dataSource[dataPosition]
+        } else null
+    }
+
+    override fun getItemId(position: Int): Long {
+        return position.toLong()
+    }
+
+    override fun getItemCount(): Int {
+        return if (dataSource == null) {
+            HEADER_COUNT
+        } else dataSource.size + HEADER_COUNT
+    }
+
+    override fun getItemViewType(position: Int): Int {
+        return if (position == 0) {
+            ITEM_TYPE_HEADER
+        } else ITEM_TYPE_NORMAL
+    }
+
+    private fun setCheckBoxStatus(holder: RecentCallsItemHolder?) {
+        if (holder?.checkBoxSelectCall == null) {
+            return
+        }
+        if (!isMultiSelectMode) {
+            holder.checkBoxSelectCall.visibility = View.GONE
+            holder.itemView.setOnClickListener(null)
+        } else {
+            holder.checkBoxSelectCall.visibility = View.VISIBLE
+        }
+    }
+
+    private fun getIndexInAdapter(records: CallRecords): Int {
+        var position = -1
+        if (dataSource.size > 0) {
+            val indexInData = dataSource.indexOf(records)
+            if (indexInData != -1) {
+                position = indexInData + HEADER_COUNT
+            }
+        }
+        return position
+    }
+
+    fun setShowMultiSelectCheckBox(show: Boolean) {
+        isMultiSelectMode = show
+        for (records in dataSource) {
+            if (records == null || TextUtils.isEmpty(records.callId)) {
+                continue
+            }
+            setItemChecked(records.callId, show)
+            val currentPosition = getIndexInAdapter(records)
+            if (currentPosition != -1) {
+                notifyItemChanged(currentPosition)
+            }
+        }
+    }
+
+    private fun setItemChecked(callId: String, isChecked: Boolean) {
+        selectedPositions[callId] = isChecked
+    }
+
+    val selectedItem: List<CallRecords>?
+        get() {
+            if (selectedPositions.size == 0) {
+                return null
+            }
+            val selectList: MutableList<CallRecords> = ArrayList()
+            for (i in 0 until itemCount) {
+                val records = getItem(i)
+                if (records != null && isItemChecked(records.callId)) {
+                    selectList.add(records)
+                }
+            }
+            return selectList
+        }
+
+    private fun isItemChecked(id: String): Boolean {
+        if (selectedPositions.size <= 0) {
+            return false
+        }
+        return if (selectedPositions.containsKey(id)) {
+            selectedPositions[id] ?: false
+        } else {
+            false
+        }
+    }
+
+    private val isRTL: Boolean
+        private get() {
+            val configuration = context.resources.configuration
+            val layoutDirection = configuration.layoutDirection
+            return layoutDirection == View.LAYOUT_DIRECTION_RTL
+        }
+
+    internal class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
+    companion object {
+        private const val ITEM_TYPE_HEADER = 101
+        private const val ITEM_TYPE_NORMAL = -98
+        private const val HEADER_COUNT = 1
+    }
+}

+ 76 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecentCallsViewModel.kt

@@ -0,0 +1,76 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents
+
+import android.app.Application
+import android.text.TextUtils
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallRecords
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.RecentCallsFilter
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+
+class RecentCallsViewModel(application: Application) : AndroidViewModel(application) {
+    val callHistoryList = MutableLiveData<MutableList<CallRecords>?>()
+    val callMissedList = MutableLiveData<MutableList<CallRecords>?>()
+
+    init {
+        callHistoryList.value = ArrayList()
+        callMissedList.value = ArrayList()
+    }
+
+    fun queryRecentCalls(filter: RecentCallsFilter?) {
+        TUICallEngine.createInstance(getApplication())
+            .queryRecentCalls(filter, object : TUICommonDefine.ValueCallback<Any?> {
+                override fun onSuccess(data: Any?) {
+                    if (data == null || data !is List<*>) {
+                        return
+                    }
+                    val queryList = data as List<CallRecords>
+                    if (filter != null && CallRecords.Result.Missed == filter.result) {
+                        val missList = callMissedList.value
+                        if (missList != null) {
+                            missList.removeAll(queryList)
+                            missList.addAll(queryList)
+                        }
+                        callMissedList.setValue(missList)
+                    } else {
+                        val historyList = callHistoryList.value
+                        if (historyList != null) {
+                            historyList.removeAll(queryList)
+                            historyList.addAll(queryList)
+                        }
+                        callHistoryList.setValue(historyList)
+                    }
+                }
+
+                override fun onError(errCode: Int, errMsg: String) {}
+            })
+    }
+
+    fun deleteRecordCalls(list: List<CallRecords>?) {
+        if (list.isNullOrEmpty()) {
+            return
+        }
+        val missList = ArrayList(callMissedList.value)
+        missList.removeAll(list)
+        callMissedList.value = missList
+        val allList: MutableList<CallRecords> = ArrayList(callHistoryList.value)
+        allList.removeAll(list)
+        callHistoryList.value = allList
+        val callIdList: MutableList<String> = ArrayList()
+        for (record in list) {
+            if (record != null && !TextUtils.isEmpty(record.callId)) {
+                callIdList.add(record.callId)
+            }
+        }
+        TUICallEngine.createInstance(getApplication()).deleteRecordCalls(callIdList,
+            object : TUICommonDefine.ValueCallback<Any?> {
+                override fun onSuccess(data: Any?) {}
+                override fun onError(errCode: Int, errMsg: String) {}
+            })
+    }
+
+    companion object {
+        private const val TAG = "RecentCallsViewModel"
+    }
+}

+ 70 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/RecordsIconView.kt

@@ -0,0 +1,70 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents
+
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.RoundCornerImageView
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage.GridImageSynthesizer
+
+class RecordsIconView : RoundCornerImageView {
+    private var imageSize = 100
+    private var background = Color.parseColor("#cfd3d8")
+    private var defaultImageResId = 0
+    private var imageGap = 6
+    private lateinit var gridImageSynthesizer: GridImageSynthesizer
+
+    constructor(context: Context) : super(context) {
+        init(context)
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
+        initAttrs(attrs)
+        init(context)
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
+        initAttrs(attrs)
+        init(context)
+    }
+
+    private fun initAttrs(attributeSet: AttributeSet?) {
+        val ta = context.obtainStyledAttributes(attributeSet, R.styleable.SynthesizedImageView)
+        if (null != ta) {
+            background = ta.getColor(R.styleable.SynthesizedImageView_image_background, background)
+            defaultImageResId = ta.getResourceId(R.styleable.SynthesizedImageView_default_image, defaultImageResId)
+            imageSize = ta.getDimensionPixelSize(R.styleable.SynthesizedImageView_image_size, imageSize)
+            imageGap = ta.getDimensionPixelSize(R.styleable.SynthesizedImageView_image_gap, imageGap)
+            ta.recycle()
+        }
+    }
+
+    private fun init(context: Context) {
+        gridImageSynthesizer = GridImageSynthesizer(context, this)
+        gridImageSynthesizer.setMaxSize(imageSize, imageSize)
+        gridImageSynthesizer.defaultImage = defaultImageResId
+        gridImageSynthesizer.setBgColor(background)
+        gridImageSynthesizer.setGap(imageGap)
+    }
+
+    fun displayImage(imageUrls: List<Any?>?): RecordsIconView {
+        gridImageSynthesizer.setImageUrls(imageUrls)
+        return this
+    }
+
+    fun setImageId(id: String?) {
+        if (id == null) {
+            gridImageSynthesizer.imageId = ""
+        } else {
+            gridImageSynthesizer.imageId = id
+        }
+    }
+
+    fun load(imageId: String?) {
+        gridImageSynthesizer.load(imageId)
+    }
+
+    fun clear() {
+        gridImageSynthesizer.clearImage()
+    }
+}

+ 10 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/extensions/recents/interfaces/ICallRecordItemListener.kt

@@ -0,0 +1,10 @@
+package com.tencent.qcloud.tuikit.tuicallkit.extensions.recents.interfaces
+
+import android.view.View
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallRecords
+
+interface ICallRecordItemListener {
+    fun onItemClick(view: View?, viewType: Int, callRecords: CallRecords?)
+    fun onItemDeleteClick(view: View?, viewType: Int, callRecords: CallRecords?)
+    fun onDetailViewClick(view: View?, callRecords: CallRecords?)
+}

+ 141 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/ServiceInitializer.kt

@@ -0,0 +1,141 @@
+package com.tencent.qcloud.tuikit.tuicallkit.internal
+
+import android.app.Activity
+import android.app.Application
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.permission.PermissionRequester
+import com.tencent.qcloud.tuikit.tuicallkit.TUICallKitImpl
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.DeviceUtils
+import com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatWindowService
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatingWindowGroupView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatingWindowView
+
+/**
+ * `TUICallKit` uses `ContentProvider` to be registered with `TUICore`.
+ * (`TUICore` is the connection and communication class of each component)
+ */
+class ServiceInitializer : ContentProvider() {
+    fun init(context: Context?) {
+        val callingService: TUICallKitService = TUICallKitService.sharedInstance(context!!)
+        TUICore.registerService(TUIConstants.TUICalling.SERVICE_NAME, callingService)
+
+        val audioRecordService = TUIAudioMessageRecordService(context)
+        TUICore.registerService(TUIConstants.TUICalling.SERVICE_NAME_AUDIO_RECORD, audioRecordService)
+
+        if (context is Application) {
+            context.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
+                private var foregroundActivities = 0
+                private var isChangingConfiguration = false
+                override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
+                override fun onActivityStarted(activity: Activity) {
+                    if (isMiPushActivity(activity)) {
+                        return
+                    }
+
+                    foregroundActivities++
+                    if (foregroundActivities == 1 && !isChangingConfiguration) {
+                        //  The Call page exits the background and re-enters without repeatedly pulling up the page.
+                        if (TUILogin.isUserLogined()
+                            && activity !is CallKitActivity
+                            && !DeviceUtils.isServiceRunning(context, FloatWindowService::class.java.name)
+                        ) {
+                            TUICallKitImpl.createInstance(context).queryOfflineCall()
+                        }
+                    }
+                    isChangingConfiguration = false
+                }
+
+                override fun onActivityResumed(activity: Activity) {}
+                override fun onActivityPaused(activity: Activity) {}
+                override fun onActivityStopped(activity: Activity) {
+                    if (isMiPushActivity(activity)) {
+                        return
+                    }
+                    foregroundActivities--
+                    isChangingConfiguration = activity.isChangingConfigurations
+
+                    if (foregroundActivities == 0 && !isChangingConfiguration) {
+                        checkToShowFloatWindow(context)
+                    }
+                }
+
+                override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+                override fun onActivityDestroyed(activity: Activity) {}
+            })
+        }
+    }
+
+    private fun isMiPushActivity(activity: Activity): Boolean {
+        try {
+            val clazzName = activity.componentName.className
+            return clazzName.contains("mipush.sdk")
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+        return false
+    }
+
+    private fun checkToShowFloatWindow(context: Context) {
+        if (TUICallDefine.Status.None == TUICallState.instance.selfUser.get().callStatus.get()) {
+            return
+        }
+
+        if (!PermissionRequester.newInstance(PermissionRequester.FLOAT_PERMISSION).has()) {
+            return
+        }
+
+        if (DeviceUtils.isServiceRunning(context, FloatWindowService::class.java.name)) {
+            return
+        }
+
+        if (TUICallState.instance.scene.get() == TUICallDefine.Scene.GROUP_CALL) {
+            FloatWindowService.startFloatService(FloatingWindowGroupView(context.applicationContext))
+        } else {
+            FloatWindowService.startFloatService(FloatingWindowView(context.applicationContext))
+        }
+        CallKitActivity.finishActivity()
+    }
+
+    override fun onCreate(): Boolean {
+        val appContext = context!!.applicationContext
+        init(appContext)
+        return false
+    }
+
+    override fun query(
+        uri: Uri, projection: Array<String>?, selection: String?,
+        selectionArgs: Array<String>?, sortOrder: String?
+    ): Cursor? {
+        return null
+    }
+
+    override fun getType(uri: Uri): String? {
+        return null
+    }
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? {
+        return null
+    }
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
+        return 0
+    }
+
+    override fun update(
+        uri: Uri, values: ContentValues?, selection: String?,
+        selectionArgs: Array<String>?
+    ): Int {
+        return 0
+    }
+}

+ 306 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/TUIAudioMessageRecordService.kt

@@ -0,0 +1,306 @@
+package com.tencent.qcloud.tuikit.tuicallkit.internal
+
+import android.content.Context
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+import android.os.Bundle
+import android.text.TextUtils
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.call.TUICallObserver
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.interfaces.ITUINotification
+import com.tencent.qcloud.tuicore.interfaces.ITUIService
+import com.tencent.qcloud.tuicore.interfaces.TUIServiceCallback
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.trtc.TRTCCloud
+import com.tencent.trtc.TRTCCloudDef
+import com.tencent.trtc.TRTCCloudListener
+import org.json.JSONException
+import org.json.JSONObject
+
+class TUIAudioMessageRecordService(context: Context) : ITUIService, ITUINotification {
+    private var context: Context = context.applicationContext
+    private var mAudioRecordInfo: AudioRecordInfo? = null
+    private var mFocusRequest: AudioFocusRequest? = null
+    private var mAudioManager: AudioManager? = null
+    private var mOnFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
+    private var mAudioRecordValueCallback: TUIServiceCallback? = null
+
+    override fun onCall(method: String?, param: Map<String?, Any?>?, callback: TUIServiceCallback?): Any {
+        mAudioRecordValueCallback = callback
+        if (TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_START_RECORD_AUDIO_MESSAGE, method)) {
+            if (param == null) {
+                Logger.error(TAG, "startRecordAudioMessage failed, param is empty")
+                notifyAudioMessageRecordEvent(
+                    TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START,
+                    TUIConstants.TUICalling.ERROR_INVALID_PARAM,
+                    null
+                )
+                return false
+            }
+
+            if (TUICallDefine.Status.None != TUICallState.instance.selfUser.get().callStatus.get()) {
+                Logger.error(TAG, "startRecordAudioMessage failed, The current call status does not support recording")
+                notifyAudioMessageRecordEvent(
+                    TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START,
+                    TUIConstants.TUICalling.ERROR_STATUS_IN_CALL,
+                    null
+                )
+                return false
+            }
+
+            if (mAudioRecordInfo != null) {
+                Logger.error(
+                    TAG, "startRecordAudioMessage failed, The recording is not over, It cannot be called again"
+                )
+                notifyAudioMessageRecordEvent(
+                    TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START,
+                    TUIConstants.TUICalling.ERROR_STATUS_IS_AUDIO_RECORDING,
+                    null
+                )
+                return false
+            }
+
+            PermissionRequest.requestPermissions(context, TUICallDefine.MediaType.Audio, object : PermissionCallback() {
+                override fun onGranted() {
+                    initAudioFocusManager()
+                    if (requestAudioFocus() != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+                        Logger.error(TAG, "startRecordAudioMessage failed, Failed to obtain audio focus")
+                        notifyAudioMessageRecordEvent(
+                            TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START,
+                            TUIConstants.TUICalling.ERROR_REQUEST_AUDIO_FOCUS_FAILED,
+                            null
+                        )
+                        return
+                    }
+                    mAudioRecordInfo = AudioRecordInfo()
+                    if (param.containsKey(TUIConstants.TUICalling.PARAM_NAME_AUDIO_PATH)) {
+                        mAudioRecordInfo!!.path = param[TUIConstants.TUICalling.PARAM_NAME_AUDIO_PATH] as String?
+                    }
+                    if (param.containsKey(TUIConstants.TUICalling.PARAM_NAME_SDK_APP_ID)) {
+                        mAudioRecordInfo!!.sdkAppId = param[TUIConstants.TUICalling.PARAM_NAME_SDK_APP_ID] as Int
+                    }
+                    if (param.containsKey(TUIConstants.TUICalling.PARAM_NAME_AUDIO_SIGNATURE)) {
+                        mAudioRecordInfo!!.signature =
+                            param[TUIConstants.TUICalling.PARAM_NAME_AUDIO_SIGNATURE] as String?
+                    }
+
+                    TRTCCloud.sharedInstance(context).addListener(mTRTCCloudListener)
+                    startRecordAudioMessage()
+                }
+
+                override fun onDenied() {
+                    super.onDenied()
+                    notifyAudioMessageRecordEvent(
+                        TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START,
+                        TUIConstants.TUICalling.ERROR_MIC_PERMISSION_REFUSED,
+                        null
+                    )
+                }
+            })
+            return true
+        }
+        if (TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_STOP_RECORD_AUDIO_MESSAGE, method)) {
+            stopRecordAudioMessage()
+        }
+        return true
+    }
+
+    private val mTRTCCloudListener: TRTCCloudListener = object : TRTCCloudListener() {
+        override fun onError(errCode: Int, errMsg: String, extraInfo: Bundle) {
+            super.onError(errCode, errMsg, extraInfo)
+            if (errCode == TUIConstants.TUICalling.ERR_MIC_START_FAIL
+                || errCode == TUIConstants.TUICalling.ERR_MIC_NOT_AUTHORIZED
+                || errCode == TUIConstants.TUICalling.ERR_MIC_SET_PARAM_FAIL
+                || errCode == TUIConstants.TUICalling.ERR_MIC_OCCUPY
+            ) {
+                notifyAudioMessageRecordEvent(TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START, errCode, null)
+            }
+        }
+
+        override fun onLocalRecordBegin(errCode: Int, storagePath: String) {
+            super.onLocalRecordBegin(errCode, storagePath)
+            val tempCode = convertErrorCode("onLocalRecordBegin", errCode)
+            if (errCode == TUIConstants.TUICalling.ERROR_NONE) {
+                TRTCCloud.sharedInstance(context).startLocalAudio(TRTCCloudDef.TRTC_AUDIO_QUALITY_SPEECH)
+            }
+            notifyAudioMessageRecordEvent(TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_START, tempCode, storagePath)
+        }
+
+        override fun onLocalRecordComplete(errCode: Int, storagePath: String) {
+            super.onLocalRecordComplete(errCode, storagePath)
+            val tempCode = convertErrorCode("onLocalRecordComplete", errCode)
+            notifyAudioMessageRecordEvent(TUIConstants.TUICalling.EVENT_SUB_KEY_RECORD_STOP, tempCode, storagePath)
+        }
+    }
+    private val mCallObserver: TUICallObserver = object : TUICallObserver() {
+        override fun onCallReceived(
+            callerId: String?,
+            calleeIdList: List<String?>?,
+            groupId: String?,
+            callMediaType: TUICallDefine.MediaType?,
+            userData: String?
+        ) {
+            super.onCallReceived(callerId, calleeIdList, groupId, callMediaType, userData)
+            stopRecordAudioMessage()
+        }
+    }
+
+    init {
+        TUICore.registerEvent(
+            TUIConstants.TUILogin.EVENT_LOGIN_STATE_CHANGED,
+            TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGIN_SUCCESS,
+            this
+        )
+    }
+
+    private fun startRecordAudioMessage() {
+        if (mAudioRecordInfo == null) {
+            Logger.error(TAG, "startRecordAudioMessage failed, audioRecordInfo is empty")
+            return
+        }
+        Logger.info(TAG, "startRecordAudioMessage, mAudioRecordInfo: $mAudioRecordInfo")
+        val jsonObject = JSONObject()
+        try {
+            jsonObject.put("api", "startRecordAudioMessage")
+            val params = JSONObject()
+            params.put(TUIConstants.TUICalling.PARAM_NAME_SDK_APP_ID, mAudioRecordInfo!!.sdkAppId)
+            params.put(TUIConstants.TUICalling.PARAM_NAME_AUDIO_PATH, mAudioRecordInfo!!.path)
+            params.put("key", mAudioRecordInfo!!.signature)
+            jsonObject.put("params", params)
+            TRTCCloud.sharedInstance(context).callExperimentalAPI(jsonObject.toString())
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+    }
+
+    private fun stopRecordAudioMessage() {
+        if (mAudioRecordInfo == null) {
+            Logger.warn(TAG, "stopRecordAudioMessage, current recording status is Idle,do not need to stop")
+            return
+        }
+        val jsonObject = JSONObject()
+        try {
+            jsonObject.put("api", "stopRecordAudioMessage")
+            val params = JSONObject()
+            jsonObject.put("params", params)
+            TRTCCloud.sharedInstance(context).callExperimentalAPI(jsonObject.toString())
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+        Logger.info(TAG, "stopRecordAudioMessage, stopLocalAudio")
+        TRTCCloud.sharedInstance(context).stopLocalAudio()
+
+        mAudioRecordInfo = null
+        abandonAudioFocus()
+    }
+
+    private fun notifyAudioMessageRecordEvent(method: String, errCode: Int, path: String?) {
+        Logger.info(TAG, "notifyAudioMessageRecordEvent, method: $method, errCode: $errCode,path: $path")
+        if (mAudioRecordValueCallback != null) {
+            val bundleInfo = Bundle()
+            bundleInfo.putString(TUIConstants.TUICalling.EVENT_KEY_RECORD_AUDIO_MESSAGE, method)
+            bundleInfo.putString(TUIConstants.TUICalling.PARAM_NAME_AUDIO_PATH, path)
+            mAudioRecordValueCallback?.onServiceCallback(errCode, "", bundleInfo)
+        }
+    }
+
+    override fun onNotifyEvent(key: String, subKey: String, param: Map<String, Any>?) {
+        if (TUIConstants.TUILogin.EVENT_LOGIN_STATE_CHANGED == key && TUIConstants.TUILogin.EVENT_SUB_KEY_USER_LOGIN_SUCCESS == subKey) {
+            TUICallEngine.createInstance(context).addObserver(mCallObserver)
+        }
+    }
+
+    private fun initAudioFocusManager() {
+        if (mAudioManager == null) {
+            mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+        }
+        if (mOnFocusChangeListener == null) {
+            mOnFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
+                when (focusChange) {
+                    AudioManager.AUDIOFOCUS_GAIN -> {}
+                    AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT ->
+                        stopRecordAudioMessage()
+
+                    AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+                        //transient lost audio focus and the new focus owner doesn't require others to be silent.
+                    }
+
+                    else -> {}
+                }
+            }
+        }
+        var attributes: AudioAttributes?
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
+                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                mFocusRequest = AudioFocusRequest
+                    .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+                    .setWillPauseWhenDucked(true)
+                    .setAudioAttributes(attributes)
+                    .setOnAudioFocusChangeListener(mOnFocusChangeListener!!)
+                    .build()
+            }
+        }
+    }
+
+    private fun requestAudioFocus(): Int {
+        if (mAudioManager == null) {
+            return AudioManager.AUDIOFOCUS_REQUEST_FAILED
+        }
+        var result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            mAudioManager!!.requestAudioFocus(mFocusRequest!!)
+        } else {
+            mAudioManager!!.requestAudioFocus(
+                mOnFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
+            )
+        }
+        return result
+    }
+
+    private fun abandonAudioFocus(): Int {
+        if (mAudioManager == null) {
+            return AudioManager.AUDIOFOCUS_REQUEST_FAILED
+        }
+        val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            mAudioManager!!.abandonAudioFocusRequest(mFocusRequest!!)
+        } else {
+            mAudioManager!!.abandonAudioFocus(mOnFocusChangeListener)
+        }
+        return result
+    }
+
+    private fun convertErrorCode(method: String, errorCode: Int): Int {
+        var targetCode: Int = when (errorCode) {
+            -1 -> if ("onLocalRecordBegin" == method) TUIConstants.TUICalling.ERROR_RECORD_INIT_FAILED else TUIConstants.TUICalling.ERROR_RECORD_FAILED
+            -2 -> TUIConstants.TUICalling.ERROR_PATH_FORMAT_NOT_SUPPORT
+            -3 -> TUIConstants.TUICalling.ERROR_NO_MESSAGE_TO_RECORD
+            -4 -> TUIConstants.TUICalling.ERROR_SIGNATURE_ERROR
+            -5 -> TUIConstants.TUICalling.ERROR_SIGNATURE_EXPIRED
+            else -> TUIConstants.TUICalling.ERROR_NONE
+        }
+        return targetCode
+    }
+
+    internal inner class AudioRecordInfo {
+        var path: String? = null
+        var sdkAppId = 0
+        var signature: String? = null
+        override fun toString(): String {
+            return ("AudioRecordInfo{path=$path, SDKAppID=$sdkAppId}")
+        }
+    }
+
+    companion object {
+        private const val TAG = "TUIAudioMessageRecordService"
+    }
+}

+ 483 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/internal/TUICallKitService.kt

@@ -0,0 +1,483 @@
+package com.tencent.qcloud.tuikit.tuicallkit.internal
+
+import android.content.Context
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultCaller
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUIConstants.TUICalling.ObjectFactory.RecentCalls
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.interfaces.ITUIExtension
+import com.tencent.qcloud.tuicore.interfaces.ITUINotification
+import com.tencent.qcloud.tuicore.interfaces.ITUIObjectFactory
+import com.tencent.qcloud.tuicore.interfaces.ITUIService
+import com.tencent.qcloud.tuicore.interfaces.TUIExtensionEventListener
+import com.tencent.qcloud.tuicore.interfaces.TUIExtensionInfo
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.TUICallKit
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.joiningroupcall.JoinInGroupCallView
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.joiningroupcall.JoinInGroupCallViewModel
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.recents.RecentCallsFragment
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import org.json.JSONException
+import org.json.JSONObject
+
+class TUICallKitService private constructor(context: Context) : ITUINotification, ITUIService, ITUIExtension,
+    ITUIObjectFactory {
+    private var appContext: Context
+    private var joinInGroupCallViewModel: JoinInGroupCallViewModel? = null
+
+    init {
+        appContext = context
+        TUICore.registerEvent(
+            TUIConstants.TUILogin.EVENT_IMSDK_INIT_STATE_CHANGED,
+            TUIConstants.TUILogin.EVENT_SUB_KEY_START_INIT, this
+        )
+        TUICore.registerEvent(
+            TUIConstants.TIMPush.EVENT_IM_LOGIN_AFTER_APP_WAKEUP_KEY,
+            TUIConstants.TIMPush.EVENT_IM_LOGIN_AFTER_APP_WAKEUP_SUB_KEY, this
+        )
+
+        TUICore.registerService(TUIConstants.TUICalling.SERVICE_NAME, this)
+
+        TUICore.registerExtension(TUIConstants.TUIChat.Extension.InputMore.CLASSIC_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIChat.Extension.InputMore.MINIMALIST_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIContact.Extension.GroupProfileItem.MINIMALIST_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIContact.Extension.GroupProfileItem.CLASSIC_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIContact.Extension.FriendProfileItem.CLASSIC_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIContact.Extension.FriendProfileItem.MINIMALIST_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.CLASSIC_EXTENSION_ID, this)
+        TUICore.registerExtension(TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.MINIMALIST_EXTENSION_ID, this)
+
+        TUICore.registerObjectFactory(TUIConstants.TUICalling.ObjectFactory.FACTORY_NAME, this)
+        TUICore.registerExtension(TUIConstants.TUIChat.Extension.ChatViewTopAreaExtension.EXTENSION_ID, this)
+    }
+
+    override fun onNotifyEvent(key: String?, subKey: String?, param: Map<String, Any>?) {
+        if (TextUtils.isEmpty(key) || TextUtils.isEmpty(subKey)) {
+            return
+        }
+        if (TUIConstants.TUILogin.EVENT_IMSDK_INIT_STATE_CHANGED == key
+            && TUIConstants.TUILogin.EVENT_SUB_KEY_START_INIT == subKey
+        ) {
+            TUICallKit.createInstance(appContext)
+            adaptiveComponentReport()
+            setExcludeFromHistoryMessage()
+        }
+        if (TUIConstants.TIMPush.EVENT_IM_LOGIN_AFTER_APP_WAKEUP_KEY == key
+            && TUIConstants.TIMPush.EVENT_IM_LOGIN_AFTER_APP_WAKEUP_SUB_KEY == subKey
+        ) {
+            val data =
+                param?.get(TUIConstants.TIMPush.EVENT_IM_LOGIN_AFTER_APP_WAKEUP_PUSH_MESSAGE_KEY) as Map<String, String>
+            Log.i(TAG, "onNotifyEvent: callOfflineData : $data")
+
+            val map = HashMap<String, Any?>()
+            map[TUIConstants.TIMPush.NOTIFICATION.PUSH_ID] = data[TUIConstants.TIMPush.NOTIFICATION.PUSH_ID]
+            map[TUIConstants.TIMPush.NOTIFICATION.PUSH_EVENT_TIME_KEY] = System.currentTimeMillis() / 1000
+            map[TUIConstants.TIMPush.NOTIFICATION.PUSH_EVENT_TYPE_KEY] = 0
+
+            TUICore.callService(
+                TUIConstants.TIMPush.SERVICE_NAME, TUIConstants.TIMPush.METHOD_REPORT_NOTIFICATION_CLICKED, map
+            )
+        }
+    }
+
+    private fun adaptiveComponentReport() {
+        val service = TUICore.getService(TUIConstants.TUIChat.SERVICE_NAME)
+        try {
+            val params = JSONObject()
+            params.put("framework", 1)
+            if (service == null) {
+                params.put("component", 14)
+            } else {
+                params.put("component", 15)
+            }
+            params.put("language", 2)
+
+            val jsonObject = JSONObject()
+            jsonObject.put("api", "setFramework")
+            jsonObject.put("params", params)
+            TUICallEngine.createInstance(appContext).callExperimentalAPI(jsonObject.toString())
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+    }
+
+    private fun setExcludeFromHistoryMessage() {
+        if (TUICore.getService(TUIConstants.TUIChat.SERVICE_NAME) == null) {
+            return
+        }
+        try {
+            val params = JSONObject()
+            params.put("excludeFromHistoryMessage", false)
+            val jsonObject = JSONObject()
+            jsonObject.put("api", "setExcludeFromHistoryMessage")
+            jsonObject.put("params", params)
+            TUICallEngine.createInstance(appContext).callExperimentalAPI(jsonObject.toString())
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+    }
+
+    companion object {
+        private const val TAG = "TUICallKitService"
+        private const val CALL_MEMBER_LIMIT = 9
+
+        fun sharedInstance(context: Context): TUICallKitService {
+            return TUICallKitService(context)
+        }
+    }
+
+    override fun onRaiseExtension(extensionID: String?, parentView: View?, param: MutableMap<String, Any>?): Boolean {
+        if (extensionID != TUIConstants.TUIChat.Extension.ChatViewTopAreaExtension.EXTENSION_ID || param == null) {
+            return false
+        }
+
+        val isGroupChat = param[TUIConstants.TUIChat.Extension.ChatViewTopAreaExtension.IS_GROUP] as? Boolean
+        if (isGroupChat == null || !isGroupChat) {
+            return false
+        }
+
+        val groupId = param[TUIConstants.TUIChat.Extension.ChatViewTopAreaExtension.CHAT_ID] as? String
+        if (groupId.isNullOrEmpty()) {
+            return false
+        }
+
+        if (parentView !is ViewGroup) {
+            return false
+        }
+        parentView.removeAllViews()
+
+        Log.i(TAG, "JoinInGroupCall, groupId: $groupId")
+
+        val callViewModel = getJoinInGroupCallViewModel()
+        val callView = JoinInGroupCallView(appContext)
+        callViewModel.setJoinInGroupCallView(callView)
+        callViewModel.getGroupAttributes(groupId)
+        parentView.addView(callView)
+        parentView.visibility = View.VISIBLE
+        return true
+    }
+
+    private fun getJoinInGroupCallViewModel(): JoinInGroupCallViewModel {
+        if (joinInGroupCallViewModel == null) {
+            joinInGroupCallViewModel = JoinInGroupCallViewModel(appContext)
+        }
+        return joinInGroupCallViewModel as JoinInGroupCallViewModel
+    }
+
+    override fun onGetExtension(extensionID: String?, param: Map<String?, Any?>?): List<TUIExtensionInfo?>? {
+        if (TextUtils.equals(extensionID, TUIConstants.TUIChat.Extension.InputMore.CLASSIC_EXTENSION_ID)) {
+            return getClassicChatInputMoreExtension(param)
+        } else if (TextUtils.equals(
+                extensionID, TUIConstants.TUIContact.Extension.GroupProfileItem.MINIMALIST_EXTENSION_ID
+            )
+        ) {
+            return getMinimalistGroupProfileExtension(param)
+        } else if (TextUtils.equals(
+                extensionID, TUIConstants.TUIContact.Extension.FriendProfileItem.CLASSIC_EXTENSION_ID
+            )
+        ) {
+            return getClassicFriendProfileExtension(param)
+        } else if (TextUtils.equals(
+                extensionID, TUIConstants.TUIContact.Extension.FriendProfileItem.MINIMALIST_EXTENSION_ID
+            )
+        ) {
+            return getMinimalistFriendProfileExtension(param)
+        } else if (TextUtils.equals(
+                extensionID, TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.MINIMALIST_EXTENSION_ID
+            )
+        ) {
+            return getMinimalistChatNavigationMoreExtension(param)
+        }
+        return null
+    }
+
+    private fun getClassicChatInputMoreExtension(param: Map<String?, Any?>?): List<TUIExtensionInfo>? {
+        val voiceCallExtension = TUIExtensionInfo()
+        voiceCallExtension.weight = 600
+        val videoCallExtension = TUIExtensionInfo()
+        videoCallExtension.weight = 500
+        val userID: String? = getOrDefault<String>(param, TUIConstants.TUIChat.Extension.InputMore.USER_ID, null)
+        val groupID: String? = getOrDefault<String>(param, TUIConstants.TUIChat.Extension.InputMore.GROUP_ID, null)
+        val voiceListener: ResultTUIExtensionEventListener = ResultTUIExtensionEventListener()
+        voiceListener.mediaType = TUICallDefine.MediaType.Audio
+        voiceListener.userID = userID
+        voiceListener.groupID = groupID
+        val videoListener: ResultTUIExtensionEventListener =
+            ResultTUIExtensionEventListener()
+        videoListener.mediaType = TUICallDefine.MediaType.Video
+        videoListener.userID = userID
+        videoListener.groupID = groupID
+        voiceCallExtension.text = appContext.getString(R.string.tuicallkit_audio_call)
+        voiceCallExtension.icon = R.drawable.tuicallkit_ic_audio_call
+        voiceCallExtension.extensionListener = voiceListener
+        voiceListener.activityResultCaller = getOrDefault<ActivityResultCaller>(
+            param,
+            TUIConstants.TUIChat.Extension.InputMore.CONTEXT, null
+        )
+        videoCallExtension.text = appContext.getString(R.string.tuicallkit_video_call)
+        videoCallExtension.icon = R.drawable.tuicallkit_ic_video_call
+        videoCallExtension.extensionListener = videoListener
+        videoListener.activityResultCaller = getOrDefault<ActivityResultCaller>(
+            param,
+            TUIConstants.TUIChat.Extension.InputMore.CONTEXT, null
+        )
+        val filterVoice: Boolean =
+            getOrDefault<Boolean>(param, TUIConstants.TUIChat.Extension.InputMore.FILTER_VOICE_CALL, false) == true
+        val filterVideo: Boolean =
+            getOrDefault<Boolean>(param, TUIConstants.TUIChat.Extension.InputMore.FILTER_VIDEO_CALL, false) == true
+        val extensionInfoList: MutableList<TUIExtensionInfo> = ArrayList()
+        if (!filterVoice) {
+            extensionInfoList.add(voiceCallExtension)
+        }
+        if (!filterVideo) {
+            extensionInfoList.add(videoCallExtension)
+        }
+        return extensionInfoList
+    }
+
+    inner class ResultTUIExtensionEventListener : TUIExtensionEventListener() {
+        var activityResultCaller: ActivityResultCaller? = null
+        var mediaType: TUICallDefine.MediaType? = null
+        var isClassicUI = true
+        var userID: String? = null
+        var groupID: String? = null
+        override fun onClicked(param: Map<String, Any>?) {
+            if (!TextUtils.isEmpty(groupID)) {
+                var groupMemberSelectActivityName =
+                    TUIConstants.TUIContact.StartActivity.GroupMemberSelect.CLASSIC_ACTIVITY_NAME
+                if (!isClassicUI) {
+                    groupMemberSelectActivityName =
+                        TUIConstants.TUIContact.StartActivity.GroupMemberSelect.MINIMALIST_ACTIVITY_NAME
+                }
+                val bundle = Bundle()
+                bundle.putString(TUIConstants.TUIContact.StartActivity.GroupMemberSelect.GROUP_ID, groupID)
+                bundle.putBoolean(TUIConstants.TUIContact.StartActivity.GroupMemberSelect.SELECT_FOR_CALL, true)
+                bundle.putInt(TUIConstants.TUIContact.StartActivity.GroupMemberSelect.MEMBER_LIMIT, CALL_MEMBER_LIMIT)
+                TUICore.startActivityForResult(
+                    activityResultCaller, groupMemberSelectActivityName, bundle
+                ) { result: ActivityResult ->
+                    val data = result.data
+                    if (data != null) {
+                        val stringList: ArrayList<String>? = data.getStringArrayListExtra(
+                            TUIConstants.TUIContact.StartActivity.GroupMemberSelect.DATA_LIST
+                        )
+                        TUICallKit.createInstance(appContext).groupCall(groupID!!, stringList, mediaType!!)
+                    }
+                }
+            } else if (!TextUtils.isEmpty(userID)) {
+                TUICallKit.createInstance(appContext).call(userID!!, mediaType!!)
+            } else {
+                Log.e(TAG, "onClicked event ignored, groupId is empty or userId is empty, cannot start call")
+            }
+        }
+    }
+
+    private fun getMinimalistGroupProfileExtension(param: Map<String?, Any?>?): List<TUIExtensionInfo>? {
+        val voiceCallExtension = TUIExtensionInfo()
+        voiceCallExtension.weight = 200
+        val videoCallExtension = TUIExtensionInfo()
+        videoCallExtension.weight = 100
+        val groupID = getOrDefault<String?>(param, TUIConstants.TUIContact.Extension.GroupProfileItem.GROUP_ID, null)
+        val voiceListener = ResultTUIExtensionEventListener()
+        voiceListener.mediaType = TUICallDefine.MediaType.Audio
+        voiceListener.groupID = groupID
+        voiceListener.isClassicUI = false
+        val videoListener = ResultTUIExtensionEventListener()
+        videoListener.mediaType = TUICallDefine.MediaType.Video
+        videoListener.groupID = groupID
+        videoListener.isClassicUI = false
+        voiceCallExtension.text = appContext.getString(R.string.tuicallkit_audio_call)
+        voiceCallExtension.icon = R.drawable.tuicallkit_profile_minimalist_audio_icon
+        voiceCallExtension.extensionListener = voiceListener
+        voiceListener.activityResultCaller = getOrDefault<ActivityResultCaller?>(
+            param,
+            TUIConstants.TUIContact.Extension.GroupProfileItem.CONTEXT, null
+        )
+        voiceListener.isClassicUI = false
+        videoCallExtension.text = appContext.getString(R.string.tuicallkit_video_call)
+        videoCallExtension.icon = R.drawable.tuicallkit_profile_minimalist_video_icon
+        videoCallExtension.extensionListener = videoListener
+        videoListener.isClassicUI = false
+        videoListener.activityResultCaller = getOrDefault<ActivityResultCaller?>(
+            param,
+            TUIConstants.TUIContact.Extension.GroupProfileItem.CONTEXT, null
+        )
+        val extensionInfoList: MutableList<TUIExtensionInfo> = java.util.ArrayList()
+        extensionInfoList.add(videoCallExtension)
+        extensionInfoList.add(voiceCallExtension)
+        return extensionInfoList
+    }
+
+    private fun getClassicFriendProfileExtension(param: Map<String?, Any?>?): List<TUIExtensionInfo>? {
+        val voiceCallExtension = TUIExtensionInfo()
+        voiceCallExtension.weight = 300
+        val videoCallExtension = TUIExtensionInfo()
+        videoCallExtension.weight = 200
+        val userID = getOrDefault<String?>(param, TUIConstants.TUIContact.Extension.FriendProfileItem.USER_ID, null)
+        val voiceListener = ResultTUIExtensionEventListener()
+        voiceListener.mediaType = TUICallDefine.MediaType.Audio
+        voiceListener.userID = userID
+        val videoListener = ResultTUIExtensionEventListener()
+        videoListener.mediaType = TUICallDefine.MediaType.Video
+        videoListener.userID = userID
+        voiceCallExtension.text = appContext.getString(R.string.tuicallkit_audio_call)
+        voiceCallExtension.extensionListener = voiceListener
+        videoCallExtension.text = appContext.getString(R.string.tuicallkit_video_call)
+        videoCallExtension.extensionListener = videoListener
+        val extensionInfoList: MutableList<TUIExtensionInfo> = java.util.ArrayList()
+        extensionInfoList.add(videoCallExtension)
+        extensionInfoList.add(voiceCallExtension)
+        return extensionInfoList
+    }
+
+    private fun getMinimalistFriendProfileExtension(param: Map<String?, Any?>?): List<TUIExtensionInfo>? {
+        val voiceCallExtension = TUIExtensionInfo()
+        voiceCallExtension.weight = 300
+        val videoCallExtension = TUIExtensionInfo()
+        videoCallExtension.weight = 200
+        val userID = getOrDefault<String?>(param, TUIConstants.TUIContact.Extension.FriendProfileItem.USER_ID, null)
+        val voiceListener = ResultTUIExtensionEventListener()
+        voiceListener.mediaType = TUICallDefine.MediaType.Audio
+        voiceListener.userID = userID
+        voiceListener.isClassicUI = false
+        val videoListener = ResultTUIExtensionEventListener()
+        videoListener.mediaType = TUICallDefine.MediaType.Video
+        videoListener.userID = userID
+        videoListener.isClassicUI = false
+        voiceCallExtension.icon = R.drawable.tuicallkit_profile_minimalist_audio_icon
+        voiceCallExtension.text = appContext.getString(R.string.tuicallkit_audio_call)
+        voiceCallExtension.extensionListener = voiceListener
+        videoCallExtension.icon = R.drawable.tuicallkit_profile_minimalist_video_icon
+        videoCallExtension.text = appContext.getString(R.string.tuicallkit_video_call)
+        videoCallExtension.extensionListener = videoListener
+        val extensionInfoList: MutableList<TUIExtensionInfo> = java.util.ArrayList()
+        extensionInfoList.add(videoCallExtension)
+        extensionInfoList.add(voiceCallExtension)
+        return extensionInfoList
+    }
+
+    private fun getMinimalistChatNavigationMoreExtension(param: Map<String?, Any?>?): List<TUIExtensionInfo>? {
+        val userID = getOrDefault<String?>(param, TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.USER_ID, null)
+        val groupID = getOrDefault<String?>(param, TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.GROUP_ID, null)
+        val voiceListener = ResultTUIExtensionEventListener()
+        voiceListener.mediaType = TUICallDefine.MediaType.Audio
+        voiceListener.groupID = groupID
+        voiceListener.userID = userID
+        voiceListener.isClassicUI = false
+        voiceListener.activityResultCaller = getOrDefault<ActivityResultCaller?>(
+            param,
+            TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.CONTEXT, null
+        )
+        val videoListener = ResultTUIExtensionEventListener()
+        videoListener.mediaType = TUICallDefine.MediaType.Video
+        videoListener.groupID = groupID
+        videoListener.userID = userID
+        videoListener.isClassicUI = false
+        videoListener.activityResultCaller = getOrDefault<ActivityResultCaller?>(
+            param,
+            TUIConstants.TUIChat.Extension.ChatNavigationMoreItem.CONTEXT, null
+        )
+        val voiceCallExtension = TUIExtensionInfo()
+        val videoCallExtension = TUIExtensionInfo()
+        voiceCallExtension.icon = R.drawable.tuicallkit_chat_title_bar_minimalist_audio_call_icon
+        voiceCallExtension.extensionListener = voiceListener
+        videoCallExtension.icon = R.drawable.tuicallkit_chat_title_bar_minimalist_video_call_icon
+        videoCallExtension.extensionListener = videoListener
+        val extensionInfoList: MutableList<TUIExtensionInfo> = java.util.ArrayList()
+        extensionInfoList.add(voiceCallExtension)
+        extensionInfoList.add(videoCallExtension)
+        return extensionInfoList
+    }
+
+    private fun <T> getOrDefault(map: Map<*, *>?, key: Any, defaultValue: T?): T? {
+        if (map == null || map.isEmpty()) {
+            return defaultValue
+        }
+        val value = map[key]
+        try {
+            if (value != null) {
+                return value as T
+            }
+        } catch (e: ClassCastException) {
+            return defaultValue
+        }
+        return defaultValue
+    }
+
+    override fun onCall(method: String?, param: Map<String?, Any?>?): Any? {
+        Log.i(TAG, "onCall, method: $method ,param: $param")
+        if (TextUtils.isEmpty(method)) {
+            return null
+        }
+        if (null != param && TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_ENABLE_FLOAT_WINDOW, method)) {
+            val enableFloatWindow = param[TUIConstants.TUICalling.PARAM_NAME_ENABLE_FLOAT_WINDOW] as Boolean
+            Log.i(TAG, "onCall, enableFloatWindow: $enableFloatWindow")
+            TUICallKit.createInstance(appContext).enableFloatWindow(enableFloatWindow)
+            return null
+        }
+        if (null != param && TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_ENABLE_MULTI_DEVICE, method)) {
+            val enable = param[TUIConstants.TUICalling.PARAM_NAME_ENABLE_MULTI_DEVICE] as Boolean
+            Log.i(TAG, "onCall, enableMultiDevice: $enable")
+            TUICallEngine.createInstance(appContext)
+                .enableMultiDeviceAbility(enable, object : TUICommonDefine.Callback {
+                    override fun onSuccess() {}
+                    override fun onError(errCode: Int, errMsg: String) {}
+                })
+            return null
+        }
+        if (param != null && TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_ENABLE_INCOMING_BANNER, method)) {
+            val enable = param[TUIConstants.TUICalling.PARAM_NAME_ENABLE_INCOMING_BANNER] as Boolean
+            TUICallKit.createInstance(appContext).enableIncomingBanner(enable)
+            return null
+        }
+        if (param != null && TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_ENABLE_VIRTUAL_BACKGROUND, method)) {
+            val enable = param[TUIConstants.TUICalling.PARAM_NAME_ENABLE_VIRTUAL_BACKGROUND] as Boolean
+            TUICallKit.createInstance(appContext).enableVirtualBackground(enable)
+            return null
+        }
+        if (null != param && TextUtils.equals(TUIConstants.TUICalling.METHOD_NAME_CALL, method)) {
+            val userIDs = param[TUIConstants.TUICalling.PARAM_NAME_USERIDS] as Array<String>?
+            val typeString = param[TUIConstants.TUICalling.PARAM_NAME_TYPE] as String?
+            val groupID = param[TUIConstants.TUICalling.PARAM_NAME_GROUPID] as String?
+            var userIdList: List<String?>? = userIDs?.toList() ?: ArrayList()
+            Logger.info(TAG, "onCall, groupID: $groupID, userIdList: $userIdList")
+            userIdList = userIdList?.filterNotNull()
+
+            var mediaType = TUICallDefine.MediaType.Unknown
+            if (TUIConstants.TUICalling.TYPE_AUDIO == typeString) {
+                mediaType = TUICallDefine.MediaType.Audio
+            } else if (TUIConstants.TUICalling.TYPE_VIDEO == typeString) {
+                mediaType = TUICallDefine.MediaType.Video
+            }
+
+            if (!TextUtils.isEmpty(groupID)) {
+                TUICallKit.createInstance(appContext).groupCall(groupID!!, userIdList, mediaType)
+            } else if (userIdList?.size == 1) {
+                TUICallKit.createInstance(appContext).call(userIdList[0]!!, mediaType)
+            } else {
+                Log.e(TAG, "onCall ignored, groupId is empty and userList is not 1, cannot start call or groupCall")
+            }
+        }
+        return null
+    }
+
+    override fun onCreateObject(objectName: String?, param: MutableMap<String?, Any?>?): Any? {
+        if (TextUtils.equals(objectName, RecentCalls.OBJECT_NAME)) {
+            var style = RecentCalls.UI_STYLE_MINIMALIST
+            if (param != null && param[RecentCalls.UI_STYLE] != null && RecentCalls.UI_STYLE_CLASSIC == param[RecentCalls.UI_STYLE]!!) {
+                style = RecentCalls.UI_STYLE_CLASSIC
+            }
+            return RecentCallsFragment(style)
+        }
+        return null
+    }
+}

+ 583 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/manager/EngineManager.kt

@@ -0,0 +1,583 @@
+package com.tencent.qcloud.tuikit.tuicallkit.manager
+
+import android.content.Context
+import android.text.TextUtils
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.CallParams
+import com.tencent.cloud.tuikit.engine.call.TUICallEngine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.ValueCallback
+import com.tencent.cloud.tuikit.engine.common.TUIVideoView
+import com.tencent.imsdk.BaseConstants
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuicore.util.ErrorMessageConverter
+import com.tencent.qcloud.tuicore.util.SPUtils
+import com.tencent.qcloud.tuicore.util.ToastUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.OfflinePushInfoConfig
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.CallingBellFeature
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest.requestPermissions
+import com.tencent.qcloud.tuikit.tuicallkit.utils.UserInfoUtils
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.Collections
+
+class EngineManager private constructor(context: Context) {
+
+    public val context: Context
+
+    init {
+        this.context = context.applicationContext
+    }
+
+    companion object {
+        const val TAG = "EngineManager"
+        var instance: EngineManager = EngineManager(TUIConfig.getAppContext())
+        private const val BLUR_LEVEL_HIGH = 3
+        private const val BLUR_LEVEL_CLOSE = 0
+    }
+
+    fun call(
+        userId: String?, callMediaType: TUICallDefine.MediaType?, params: TUICallDefine.CallParams?,
+        callback: TUICommonDefine.Callback?
+    ) {
+        Logger.info(TAG, "call -> {userId: $userId, callMediaType: $callMediaType, params: $params}")
+        if (TextUtils.isEmpty(userId)) {
+            Logger.error(TAG, "call failed, userId is empty")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "call failed, userId is empty")
+            return
+        }
+        if (TUICallDefine.MediaType.Unknown == callMediaType) {
+            Logger.error(TAG, "call failed, callMediaType is Unknown")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "call failed, callMediaType is Unknown")
+            return
+        }
+        TUICallState.instance.selfUser.get().avatar.set(TUILogin.getFaceUrl())
+        TUICallState.instance.selfUser.get().nickname.set(TUILogin.getNickName())
+        TUICallState.instance.selfUser.get().id = TUILogin.getLoginUser()
+        requestPermissions(context, callMediaType!!, object : PermissionCallback() {
+            override fun onGranted() {
+                TUICallEngine.createInstance(context).call(userId, callMediaType, params,
+                    object : TUICommonDefine.Callback {
+                        override fun onSuccess() {
+                            val user = User()
+                            user.id = userId
+                            user.callRole.set(TUICallDefine.Role.Called)
+                            user.callStatus.set(TUICallDefine.Status.Waiting)
+                            UserInfoUtils.updateUserInfo(user)
+                            TUICallState.instance.remoteUserList.get()?.add(user)
+                            TUICallState.instance.mediaType.set(callMediaType)
+                            TUICallState.instance.scene.set(TUICallDefine.Scene.SINGLE_CALL)
+                            TUICallState.instance.selfUser.get().callRole.set(TUICallDefine.Role.Caller)
+                            TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Waiting)
+                            initAudioPlayDevice()
+                            callback?.onSuccess()
+                        }
+
+                        override fun onError(errCode: Int, errMsg: String) {
+                            val errMessage: String = convertErrorMsg(errCode, errMsg)
+                            ToastUtil.toastLongMessage(errMessage)
+                            callback?.onError(errCode, errMessage)
+                        }
+                    })
+            }
+
+            override fun onDenied() {
+                callback?.onError(TUICallDefine.ERROR_PERMISSION_DENIED, "request Permissions failed")
+            }
+        })
+    }
+
+    fun calls(
+        userIdList: List<String?>?, mediaType: TUICallDefine.MediaType?, params: CallParams?,
+        callback: TUICommonDefine.Callback?
+    ) {
+        Logger.info(TAG, "calls, userIdList: $userIdList, callMediaType: $mediaType, params: $params")
+        if (TUICallDefine.MediaType.Audio != mediaType && TUICallDefine.MediaType.Video != mediaType) {
+            Logger.error(TAG, "calls failed, mediaType is Unknown")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "calls failed, mediaType is Unknown")
+            return
+        }
+
+        val list = userIdList?.toHashSet()?.toMutableList()
+        list?.remove(TUILogin.getLoginUser())
+        list?.removeAll(Collections.singleton(null))
+
+        if (list.isNullOrEmpty()) {
+            Logger.error(TAG, "calls failed, userIdList is empty")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "calls failed, userIdList is empty")
+            return
+        }
+        if (list.size >= Constants.MAX_USER) {
+            ToastUtil.toastLongMessage(context.getString(R.string.tuicallkit_user_exceed_limit))
+            Logger.error(TAG, "calls failed, exceeding max user number: 9")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "calls failed, exceeding max user number")
+            return
+        }
+        TUICallState.instance.selfUser.get().avatar.set(TUILogin.getFaceUrl())
+        TUICallState.instance.selfUser.get().nickname.set(TUILogin.getNickName())
+        TUICallState.instance.selfUser.get().id = TUILogin.getLoginUser()
+        requestPermissions(context, mediaType, object : PermissionCallback() {
+            override fun onGranted() {
+                TUICallEngine.createInstance(context).calls(list, mediaType, params, object : TUICommonDefine.Callback {
+                    override fun onSuccess() {
+                        for (userId in list) {
+                            if (!TextUtils.isEmpty(userId)) {
+                                val model = User()
+                                model.id = userId
+                                model.callRole.set(TUICallDefine.Role.Called)
+                                model.callStatus.set(TUICallDefine.Status.Waiting)
+                                UserInfoUtils.updateUserInfo(model)
+                                TUICallState.instance.remoteUserList.get().add(model)
+                            }
+                        }
+                        TUICallState.instance.mediaType.set(mediaType)
+                        TUICallState.instance.groupId.set(params?.chatGroupId)
+                        if (params != null && !params.chatGroupId.isNullOrEmpty() || list.size > 1) {
+                            TUICallState.instance.scene.set(TUICallDefine.Scene.GROUP_CALL)
+                        } else {
+                            TUICallState.instance.scene.set(TUICallDefine.Scene.SINGLE_CALL)
+                        }
+                        
+                        TUICallState.instance.selfUser.get().callRole.set(TUICallDefine.Role.Caller)
+                        TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Waiting)
+                        initAudioPlayDevice()
+                        callback?.onSuccess()
+                    }
+
+                    override fun onError(errCode: Int, errMsg: String) {
+                        val errMessage: String = convertErrorMsg(errCode, errMsg)
+                        ToastUtil.toastLongMessage(errMessage)
+                        Logger.error(TAG, "calls errCode:$errCode, errMsg:$errMessage")
+                        callback?.onError(errCode, errMessage)
+                    }
+                })
+            }
+
+            override fun onDenied() {
+                callback?.onError(TUICallDefine.ERROR_PERMISSION_DENIED, "request Permissions failed")
+            }
+        })
+    }
+
+    fun groupCall(
+        groupId: String?, userIdList: List<String?>?, callMediaType: TUICallDefine.MediaType,
+        params: TUICallDefine.CallParams?, callback: TUICommonDefine.Callback?
+    ) {
+        Logger.info(
+            TAG, "call -> {groupId: $groupId, userIdList: $userIdList, callMediaType: $callMediaType, params: $params}"
+        )
+        if (TextUtils.isEmpty(groupId)) {
+            Logger.error(TAG, "groupCall failed, groupId is empty")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "groupCall failed, groupId is empty")
+            return
+        }
+        if (TUICallDefine.MediaType.Unknown == callMediaType) {
+            Logger.error(TAG, "groupCall failed, callMediaType is Unknown")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "groupCall failed, callMediaType is Unknown")
+            return
+        }
+
+        val list = userIdList?.toHashSet()?.toMutableList()
+        list?.remove(TUILogin.getLoginUser())
+        list?.removeAll(Collections.singleton(null))
+
+        if (list == null || list.isEmpty()) {
+            Logger.error(TAG, "groupCall failed, userIdList is empty")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "groupCall failed, userIdList is empty")
+            return
+        }
+        if (list.size >= Constants.MAX_USER) {
+            ToastUtil.toastLongMessage(context.getString(R.string.tuicallkit_user_exceed_limit))
+            Logger.error(TAG, "groupCall failed, exceeding max user number: 9")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "groupCall failed, exceeding max user number")
+            return
+        }
+        TUICallState.instance.selfUser.get().avatar.set(TUILogin.getFaceUrl())
+        TUICallState.instance.selfUser.get().nickname.set(TUILogin.getNickName())
+        TUICallState.instance.selfUser.get().id = TUILogin.getLoginUser()
+        requestPermissions(context, callMediaType, object : PermissionCallback() {
+            override fun onGranted() {
+                TUICallEngine.createInstance(context).groupCall(
+                    groupId, list, callMediaType,
+                    params, object : TUICommonDefine.Callback {
+                        override fun onSuccess() {
+                            for (userId in list) {
+                                if (!TextUtils.isEmpty(userId)) {
+                                    val model = User()
+                                    model.id = userId
+                                    model.callRole.set(TUICallDefine.Role.Called)
+                                    model.callStatus.set(TUICallDefine.Status.Waiting)
+                                    UserInfoUtils.updateUserInfo(model)
+                                    TUICallState.instance.remoteUserList.get().add(model)
+                                }
+                            }
+                            TUICallState.instance.mediaType.set(callMediaType)
+                            TUICallState.instance.scene.set(TUICallDefine.Scene.GROUP_CALL)
+                            TUICallState.instance.groupId.set(groupId)
+
+                            TUICallState.instance.selfUser.get().callRole.set(TUICallDefine.Role.Caller)
+                            TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Waiting)
+                            initAudioPlayDevice()
+                            callback?.onSuccess()
+                        }
+
+                        override fun onError(errCode: Int, errMsg: String) {
+                            val errMessage: String = convertErrorMsg(errCode, errMsg)
+                            ToastUtil.toastLongMessage(errMessage)
+                            Logger.error(TAG, "groupCall errCode:$errCode, errMsg:$errMessage")
+                            callback?.onError(errCode, errMessage)
+                        }
+                    })
+            }
+
+            override fun onDenied() {
+                callback?.onError(TUICallDefine.ERROR_PERMISSION_DENIED, "request Permissions failed")
+            }
+        })
+    }
+
+    fun joinInGroupCall(roomId: TUICommonDefine.RoomId?, groupId: String?, mediaType: TUICallDefine.MediaType?) {
+        val intRoomId = roomId?.intRoomId ?: 0
+        val strRoomId = roomId?.strRoomId ?: ""
+        if (intRoomId <= 0 && TextUtils.isEmpty(strRoomId)) {
+            Logger.error(TAG, "joinInGroupCall failed, roomId is invalid")
+            return
+        }
+        if (TextUtils.isEmpty(groupId)) {
+            Logger.error(TAG, "joinInGroupCall failed, groupId is empty")
+            return
+        }
+        if (TUICallDefine.MediaType.Unknown == mediaType) {
+            Logger.error(TAG, "joinInGroupCall failed, mediaType is unknown")
+            return
+        }
+        TUICallState.instance.selfUser.get().avatar.set(TUILogin.getFaceUrl())
+        TUICallState.instance.selfUser.get().nickname.set(TUILogin.getNickName())
+        TUICallState.instance.selfUser.get().id = TUILogin.getLoginUser()
+        requestPermissions(context, mediaType!!, object : PermissionCallback() {
+            override fun onGranted() {
+                TUICallEngine.createInstance(context).joinInGroupCall(roomId, groupId, mediaType,
+                    object : TUICommonDefine.Callback {
+                        override fun onSuccess() {
+                            TUICallState.instance.groupId.set(groupId)
+                            TUICallState.instance.roomId.set(roomId)
+                            TUICallState.instance.mediaType.set(mediaType)
+                            TUICallState.instance.scene.set(TUICallDefine.Scene.GROUP_CALL)
+                            TUICallState.instance.selfUser.get().callRole.set(TUICallDefine.Role.Called)
+                            TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Accept)
+
+                            TUICore.notifyEvent(
+                                Constants.EVENT_TUICALLKIT_CHANGED,
+                                Constants.EVENT_START_ACTIVITY,
+                                HashMap()
+                            )
+                        }
+
+                        override fun onError(errCode: Int, errMsg: String) {
+                            val errMessage = convertErrorMsg(errCode, errMsg)
+                            ToastUtil.toastLongMessage(errMessage)
+                        }
+                    })
+            }
+
+            override fun onDenied() {
+                Logger.error(TAG, "requestPermissions failed")
+            }
+        })
+    }
+
+    fun join(callId: String?, callback: TUICommonDefine.Callback?) {
+        if (callId.isNullOrEmpty()) {
+            Logger.error(TAG, "join failed, callId is empty")
+            callback?.onError(TUICallDefine.ERROR_PARAM_INVALID, "join failed, callId is empty")
+            return
+        }
+        Logger.info(TAG, "join callId: $callId")
+        TUICallState.instance.selfUser.get().avatar.set(TUILogin.getFaceUrl())
+        TUICallState.instance.selfUser.get().nickname.set(TUILogin.getNickName())
+        TUICallState.instance.selfUser.get().id = TUILogin.getLoginUser()
+        requestPermissions(context, TUICallDefine.MediaType.Audio, object : PermissionCallback() {
+            override fun onGranted() {
+                TUICallEngine.createInstance(context).join(callId, object : TUICommonDefine.Callback {
+                    override fun onSuccess() {
+                        TUICallState.instance.scene.set(TUICallDefine.Scene.GROUP_CALL)
+                        TUICallState.instance.selfUser.get().callRole.set(TUICallDefine.Role.Called)
+                        TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Accept)
+
+                        TUICore.notifyEvent(
+                            Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_ACTIVITY, HashMap()
+                        )
+                        callback?.onSuccess()
+                    }
+
+                    override fun onError(errCode: Int, errMsg: String) {
+                        val errMessage = convertErrorMsg(errCode, errMsg)
+                        ToastUtil.toastLongMessage(errMessage)
+                        callback?.onError(errCode, errMsg)
+                    }
+                })
+            }
+
+            override fun onDenied() {
+                Logger.error(TAG, "join failed, requestPermissions denied")
+                callback?.onError(TUICallDefine.ERROR_PERMISSION_DENIED, "request Permissions failed")
+            }
+        })
+    }
+
+    fun accept(callback: TUICommonDefine.Callback?) {
+        TUICallEngine.createInstance(context).accept(object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                if (TUICallState.instance.selfUser.get().callStatus.get() != TUICallDefine.Status.Accept) {
+                    TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.Accept)
+                }
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String) {
+                TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    fun reject(callback: TUICommonDefine.Callback?) {
+        TUICallEngine.createInstance(context).reject(object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String) {
+                TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    fun hangup(callback: TUICommonDefine.Callback?) {
+        TUICallEngine.createInstance(context).hangup(object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String) {
+                TUICallState.instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    fun openCamera(camera: TUICommonDefine.Camera?, videoView: TUIVideoView?, callback: TUICommonDefine.Callback?) {
+        if (TUICore.getService(TUIConstants.USBCamera.SERVICE_NAME) != null) {
+            Logger.info(TAG, "open usb camera")
+            val map = HashMap<String, Any?>()
+            map[TUIConstants.USBCamera.PARAM_TX_CLOUD_VIEW] = videoView
+            TUICore.notifyEvent(TUIConstants.USBCamera.KEY_USB_CAMERA, TUIConstants.USBCamera.SUB_KEY_OPEN_CAMERA, map)
+            return
+        }
+
+        PermissionRequest.requestCameraPermission(context, object : PermissionCallback() {
+            override fun onGranted() {
+
+                TUICallEngine.createInstance(context).openCamera(camera, videoView, object : TUICommonDefine.Callback {
+                    override fun onSuccess() {
+                        val status: TUICallDefine.Status = TUICallState.instance.selfUser.get().callStatus.get()
+                        if (TUICallDefine.Status.None != status) {
+                            val camera: TUICommonDefine.Camera = TUICallState.instance.isFrontCamera.get()
+                            TUICallState.instance.isCameraOpen.set(true)
+                            TUICallState.instance.isFrontCamera.set(camera)
+                            TUICallState.instance.selfUser.get().videoAvailable.set(true)
+                        }
+                        callback?.onSuccess()
+                    }
+
+                    override fun onError(errCode: Int, errMsg: String) {
+                        callback?.onError(errCode, errMsg)
+                    }
+                })
+            }
+
+            override fun onDenied() {
+                Logger.warn(TAG, "refused to access to the camera")
+            }
+        })
+    }
+
+    fun closeCamera() {
+        TUICallEngine.createInstance(context).closeCamera()
+        TUICallState.instance.isCameraOpen.set(false)
+        TUICallState.instance.selfUser.get().videoAvailable.set(false)
+
+        if (TUICore.getService(TUIConstants.USBCamera.SERVICE_NAME) != null) {
+            TUICore.notifyEvent(
+                TUIConstants.USBCamera.KEY_USB_CAMERA, TUIConstants.USBCamera.SUB_KEY_CLOSE_CAMERA, null
+            )
+        }
+    }
+
+    fun switchCamera(camera: TUICommonDefine.Camera) {
+        TUICallEngine.createInstance(context).switchCamera(camera)
+        TUICallState.instance.isFrontCamera.set(camera)
+    }
+
+    fun openMicrophone(callback: TUICommonDefine.Callback?) {
+        TUICallEngine.createInstance(context).openMicrophone(object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+                val status: TUICallDefine.Status = TUICallState.instance.selfUser.get().callStatus.get()
+                if (TUICallDefine.Status.None != status) {
+                    TUICallState.instance.isMicrophoneMute.set(false)
+                    TUICallState.instance.selfUser.get().audioAvailable.set(true)
+                }
+                callback?.onSuccess()
+            }
+
+            override fun onError(errCode: Int, errMsg: String) {
+                callback?.onError(errCode, errMsg)
+            }
+        })
+    }
+
+    fun closeMicrophone() {
+        TUICallEngine.createInstance(context).closeMicrophone()
+        TUICallState.instance.isMicrophoneMute.set(true)
+        TUICallState.instance.selfUser.get().audioAvailable.set(false)
+    }
+
+    fun selectAudioPlaybackDevice(device: TUICommonDefine.AudioPlaybackDevice?) {
+        TUICallEngine.createInstance(context).selectAudioPlaybackDevice(device)
+        TUICallState.instance.audioPlayoutDevice.set(device)
+    }
+
+    fun startRemoteView(userId: String?, videoView: TUIVideoView?, callback: TUICommonDefine.PlayCallback?) {
+        TUICallEngine.createInstance(context).startRemoteView(userId, videoView, callback)
+    }
+
+    fun stopRemoteView(userId: String?) {
+        TUICallEngine.createInstance(context).stopRemoteView(userId)
+    }
+
+    fun enableFloatWindow(enable: Boolean) {
+        TUICallState.instance.enableFloatWindow = enable
+    }
+
+    fun enableMuteMode(enable: Boolean) {
+        TUICallState.instance.enableMuteMode = enable
+        SPUtils.getInstance(CallingBellFeature.PROFILE_TUICALLKIT).put(CallingBellFeature.PROFILE_MUTE_MODE, enable)
+    }
+
+    fun setBlurBackground(enable: Boolean) {
+        val level = if (enable) BLUR_LEVEL_HIGH else BLUR_LEVEL_CLOSE
+        TUICallState.instance.enableBlurBackground.set(enable)
+
+        TUICallEngine.createInstance(context).setBlurBackground(level, object : TUICommonDefine.Callback {
+            override fun onSuccess() {
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                Logger.error(TAG, "setBlurBackground failed, errCode: $errCode, errMsg: $errMsg")
+                TUICallState.instance.enableBlurBackground.set(false)
+            }
+        })
+    }
+
+    fun inviteUser(userIdList: List<String?>?) {
+        val params = CallParams()
+        params.offlinePushInfo = OfflinePushInfoConfig.createOfflinePushInfo(context)
+        params.timeout = Constants.SIGNALING_MAX_TIME
+        TUICallEngine.createInstance(context)
+            .inviteUser(userIdList, params, object : ValueCallback<List<String>> {
+                override fun onSuccess(data: List<String>) {
+                    val userList = data
+                    Logger.info(TAG, "inviteUsersToGroupCall success, list:$userList")
+                    UserInfoUtils.getUserListInfo(userList, object : ValueCallback<List<User>?> {
+                        override fun onSuccess(data: List<User>?) {
+                            if (data.isNullOrEmpty()) {
+                                Logger.error(TAG, "getUsersInfo onSuccess list = null")
+                                return
+                            }
+                            for (info in data) {
+                                info.callStatus.set(TUICallDefine.Status.Waiting)
+                                TUICallState.instance.remoteUserList.add(info)
+                            }
+                        }
+
+                        override fun onError(errCode: Int, errMsg: String?) {
+                            Logger.error(TAG, "getUsersInfo onError errorCode = $errCode , errorMsg = $errMsg")
+                        }
+                    })
+                }
+
+                override fun onError(errCode: Int, errMsg: String) {}
+            })
+    }
+
+    private fun initAudioPlayDevice() {
+        if (TUICallDefine.MediaType.Video == TUICallState.instance.mediaType.get()) {
+            selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Speakerphone)
+            TUICallState.instance.isCameraOpen.set(true)
+        } else {
+            selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Earpiece)
+            TUICallState.instance.isCameraOpen.set(false)
+        }
+    }
+
+    private fun getCommonErrorMap(): Map<Int, String> {
+        val map = HashMap<Int, String>()
+        map[TUICallDefine.ERROR_PACKAGE_NOT_PURCHASED] = context.getString(R.string.tuicallkit_package_not_purchased)
+        map[TUICallDefine.ERROR_PACKAGE_NOT_SUPPORTED] = context.getString(R.string.tuicallkit_package_not_support)
+        map[TUICallDefine.ERROR_INIT_FAIL] = context.getString(R.string.tuicallkit_error_invalid_login)
+        map[TUICallDefine.ERROR_PARAM_INVALID] = context.getString(R.string.tuicallkit_error_parameter_invalid)
+        map[TUICallDefine.ERROR_REQUEST_REFUSED] = context.getString(R.string.tuicallkit_error_request_refused)
+        map[TUICallDefine.ERROR_REQUEST_REPEATED] = context.getString(R.string.tuicallkit_error_request_repeated)
+        map[TUICallDefine.ERROR_SCENE_NOT_SUPPORTED] = context.getString(R.string.tuicallkit_error_scene_not_support)
+        return map
+    }
+
+    private fun convertErrorMsg(errorCode: Int, msg: String): String {
+        if (errorCode == BaseConstants.ERR_SVR_MSG_IN_PEER_BLACKLIST) {
+            return context.getString(R.string.tuicallkit_error_in_peer_blacklist)
+        }
+
+        val commonErrorMap = getCommonErrorMap()
+        if (commonErrorMap.containsKey(errorCode)) {
+            return commonErrorMap[errorCode]!!
+        }
+
+        return ErrorMessageConverter.convertIMError(errorCode, msg)
+    }
+
+    fun reportOnlineLog(data: Map<String, Any>) {
+        try {
+            val map: JSONObject = JSONObject(data)
+            map.put("version", TUICallDefine.VERSION)
+            map.put("platform", "android")
+            map.put("framework", "native")
+            map.put("sdk_app_id", TUILogin.getSdkAppId())
+
+            val params = JSONObject()
+            params.put("level", 1)
+            params.put("msg", map.toString())
+            params.put("more_msg", "TUICallKit")
+
+            val jsonObject = JSONObject()
+            jsonObject.put("api", "reportOnlineLog")
+            jsonObject.put("params", params)
+
+            TUICallEngine.createInstance(context).trtcCloudInstance.callExperimentalAPI(jsonObject.toString())
+        } catch (e: JSONException) {
+            e.printStackTrace()
+        }
+    }
+}

+ 519 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/state/TUICallState.kt

@@ -0,0 +1,519 @@
+package com.tencent.qcloud.tuikit.tuicallkit.state
+
+import android.os.Handler
+import android.os.HandlerThread
+import android.text.TextUtils
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallObserver
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.AudioPlaybackDevice
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.NetworkQuality
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.util.SPUtils
+import com.tencent.qcloud.tuicore.util.ToastUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.CallingBellFeature
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.UserInfoUtils
+import com.trtc.tuikit.common.foregroundservice.AudioForegroundService
+import com.trtc.tuikit.common.foregroundservice.VideoForegroundService
+import com.trtc.tuikit.common.livedata.LiveData
+
+class TUICallState {
+    public var selfUser = LiveData<User>()
+    public var remoteUserList = LiveData<LinkedHashSet<User>>()
+
+    public var scene = LiveData<TUICallDefine.Scene>()
+    public var mediaType = LiveData<TUICallDefine.MediaType>()
+    public var timeCount = LiveData<Int>()
+    public var roomId = LiveData<TUICommonDefine.RoomId>()
+    public var groupId = LiveData<String?>()
+
+    public var isCameraOpen = LiveData<Boolean>()
+    public var isFrontCamera = LiveData<TUICommonDefine.Camera>()
+    public var isMicrophoneMute = LiveData<Boolean>()
+    public var audioPlayoutDevice = LiveData<AudioPlaybackDevice>()
+
+    public var enableMuteMode = false
+    public var enableFloatWindow = true
+    public var enableIncomingBanner = false
+    public var showVirtualBackgroundButton = false
+    public var enableBlurBackground = LiveData<Boolean>()
+    public var reverse1v1CallRenderView = false
+    public var isShowFullScreen = LiveData<Boolean>()
+    public var isBottomViewExpand = LiveData<Boolean>()
+    public var showLargeViewUserId = LiveData<String>()
+    public var networkQualityReminder = LiveData<Constants.NetworkQualityHint>()
+
+    var orientation = Constants.Orientation.Portrait
+
+    private var timeHandler: Handler? = null
+    private var timeHandlerThread: HandlerThread? = null
+    private var timeRunnable: Runnable? = null
+
+    init {
+        selfUser.set(User())
+        remoteUserList.set(LinkedHashSet())
+        scene.set(null)
+        mediaType.set(TUICallDefine.MediaType.Unknown)
+        timeCount.set(0)
+        roomId.set(null)
+        groupId.set(null)
+        isCameraOpen.set(false)
+        isFrontCamera.set(TUICommonDefine.Camera.Front)
+        isMicrophoneMute.set(false)
+        audioPlayoutDevice.set(AudioPlaybackDevice.Speakerphone)
+        enableMuteMode = SPUtils.getInstance(CallingBellFeature.PROFILE_TUICALLKIT)
+            .getBoolean(CallingBellFeature.PROFILE_MUTE_MODE, false)
+        isShowFullScreen.set(false)
+        isBottomViewExpand.set(true)
+        showLargeViewUserId.set(null)
+        enableBlurBackground.set(false)
+        networkQualityReminder.set(Constants.NetworkQualityHint.None)
+    }
+
+    val mTUICallObserver: TUICallObserver = object : TUICallObserver() {
+        override fun onError(code: Int, msg: String?) {
+        }
+
+        override fun onCallReceived(
+            callerId: String?, calleeIdList: List<String?>?, groupID: String?,
+            callMediaType: TUICallDefine.MediaType?, userData: String?
+        ) {
+            Logger.info(
+                TAG, "onCallReceived -> {callerId: $callerId, calleeIdList: " +
+                        "$calleeIdList, groupId: $groupID, callMediaType: $callMediaType}"
+            )
+            if (TUICallDefine.MediaType.Unknown == callMediaType || calleeIdList.isNullOrEmpty()) {
+                return
+            }
+
+            if (calleeIdList.size >= Constants.MAX_USER) {
+                ToastUtil.toastLongMessage(TUILogin.getAppContext().getString(R.string.tuicallkit_user_exceed_limit))
+                return
+            }
+
+            groupId.set(groupID)
+            mediaType.set(callMediaType)
+
+            if (!callerId.isNullOrEmpty()) {
+                val user = User()
+                user.id = callerId
+                user.callRole.set(TUICallDefine.Role.Caller)
+                user.callStatus.set(TUICallDefine.Status.Waiting)
+                UserInfoUtils.updateUserInfo(user)
+                remoteUserList.add(user)
+            }
+
+            for (userId in calleeIdList) {
+                if (!TextUtils.isEmpty(userId) && userId != TUILogin.getLoginUser()) {
+                    val user = User()
+                    user.id = userId
+                    user.callRole.set(TUICallDefine.Role.Called)
+                    user.callStatus.set(TUICallDefine.Status.Waiting)
+                    UserInfoUtils.updateUserInfo(user)
+                    remoteUserList.add(user)
+                }
+            }
+            if (!TextUtils.isEmpty(groupID) || remoteUserList.get().size > 1) {
+                scene.set(TUICallDefine.Scene.GROUP_CALL)
+            } else {
+                scene.set(TUICallDefine.Scene.SINGLE_CALL)
+            }
+
+            selfUser.get().id = TUILogin.getUserId()
+            selfUser.get().avatar.set(TUILogin.getFaceUrl())
+            selfUser.get().nickname.set(TUILogin.getNickName())
+            selfUser.get().callRole.set(TUICallDefine.Role.Called)
+            selfUser.get().callStatus.set(TUICallDefine.Status.Waiting)
+
+            if (TUICallDefine.MediaType.Video == mediaType.get()) {
+                EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Speakerphone)
+                isCameraOpen.set(true)
+            } else {
+                EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Earpiece)
+                isCameraOpen.set(false)
+            }
+
+            TUICore.notifyEvent(Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_INCOMING_VIEW, HashMap())
+        }
+
+        override fun onCallCancelled(callerId: String?) {
+            Logger.info(TAG, "onCallCancelled -> {callerId: $callerId}")
+            resetCall()
+        }
+
+        override fun onCallBegin(
+            room: TUICommonDefine.RoomId?, callMediaType: TUICallDefine.MediaType?, callRole: TUICallDefine.Role?
+        ) {
+            Logger.info(TAG, "onCallBegin -> {room: $room, callMediaType: $callMediaType, callRole: $callRole}")
+            if (TUICallDefine.Role.Called == instance.selfUser.get().callRole.get()
+                && TUICallDefine.MediaType.Audio == instance.mediaType.get()
+            ) {
+                EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Earpiece)
+            } else {
+                EngineManager.instance.selectAudioPlaybackDevice(instance.audioPlayoutDevice.get())
+            }
+            roomId.set(room)
+            if (selfUser.get().callStatus.get() != TUICallDefine.Status.Accept) {
+                selfUser.get().callStatus.set(TUICallDefine.Status.Accept)
+            }
+            instance.reverse1v1CallRenderView = true
+            if (isMicrophoneMute.get()) {
+                EngineManager.instance.closeMicrophone()
+            } else {
+                EngineManager.instance.openMicrophone(null)
+            }
+            startTimeCount()
+            startForegroundService()
+        }
+
+        override fun onCallEnd(
+            room: TUICommonDefine.RoomId?, callMediaType: TUICallDefine.MediaType?,
+            callRole: TUICallDefine.Role?, totalTime: Long
+        ) {
+            Logger.info(TAG, "onCallEnd -> {room: $room, callMediaType: $callMediaType, callRole: $callRole")
+            roomId.set(room)
+            resetCall()
+        }
+
+        override fun onCallMediaTypeChanged(
+            oldCallMediaType: TUICallDefine.MediaType?, newCallMediaType: TUICallDefine.MediaType?
+        ) {
+            Logger.info(
+                TAG, "onCallMediaTypeChanged -> {oldCallMediaType: $oldCallMediaType"
+                        + ", newCallMediaType: $newCallMediaType}"
+            )
+            if (oldCallMediaType != newCallMediaType) {
+                mediaType.set(newCallMediaType)
+                if (newCallMediaType == TUICallDefine.MediaType.Audio) {
+                    if (TUICallDefine.Status.Accept == instance.selfUser.get().callStatus.get()) {
+                        EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Earpiece)
+                    } else {
+                        if (TUICallDefine.Role.Caller == instance.selfUser.get().callRole.get()) {
+                            EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Earpiece)
+                        } else {
+                            EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Speakerphone)
+                        }
+                    }
+                } else {
+                    EngineManager.instance.selectAudioPlaybackDevice(AudioPlaybackDevice.Speakerphone)
+                }
+            }
+        }
+
+        override fun onUserReject(userId: String?) {
+            Logger.info(TAG, "onUserReject -> {userId: $userId}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+
+            removeUserOnLeave(userId)
+            if (TUICallDefine.Scene.SINGLE_CALL == instance.scene.get()) {
+                ToastUtil.toastShortMessage(
+                    TUIConfig.getAppContext().getString(R.string.tuicallkit_toast_callee_reject)
+                )
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            } else if (remoteUserList.get().isEmpty()) {
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            }
+        }
+
+        override fun onUserNoResponse(userId: String?) {
+            Logger.info(TAG, "onUserNoResponse -> {userId: $userId}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+
+            removeUserOnLeave(userId)
+            if (TUICallDefine.Scene.SINGLE_CALL == instance.scene.get()) {
+                ToastUtil.toastShortMessage(
+                    TUIConfig.getAppContext().getString(R.string.tuicallkit_toast_callee_no_response)
+                )
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            } else if (remoteUserList.get().isEmpty()) {
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            }
+        }
+
+        override fun onUserLineBusy(userId: String?) {
+            Logger.info(TAG, "onUserLineBusy -> {userId: $userId}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+            ToastUtil.toastShortMessage(TUIConfig.getAppContext().getString(R.string.tuicallkit_text_line_busy))
+            removeUserOnLeave(userId)
+            if (TUICallDefine.Scene.SINGLE_CALL == instance.scene.get()) {
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            } else if (remoteUserList.get().isEmpty()) {
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            }
+        }
+
+        override fun onUserJoin(userId: String?) {
+            Logger.info(TAG, "onUserJoin -> {userId: $userId}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+            updateUserOnEnter(userId)
+        }
+
+        override fun onUserLeave(userId: String?) {
+            Logger.info(TAG, "onUserLeave -> {userId: $userId}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+
+            removeUserOnLeave(userId)
+            if (TUICallDefine.Scene.SINGLE_CALL == instance.scene.get()) {
+                ToastUtil.toastShortMessage(
+                    TUIConfig.getAppContext().getString(R.string.tuicallkit_toast_callee_hangup)
+                )
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            } else if (remoteUserList.get().isEmpty()) {
+                instance.selfUser.get().callStatus.set(TUICallDefine.Status.None)
+            }
+        }
+
+        override fun onUserVideoAvailable(userId: String?, isVideoAvailable: Boolean) {
+            Logger.info(TAG, "onUserVideoAvailable -> {userId: $userId, isVideoAvailable: $isVideoAvailable}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+            val user = findUser(userId)
+            if (user != null && user.videoAvailable.get() != isVideoAvailable) {
+                user.videoAvailable.set(isVideoAvailable)
+            }
+        }
+
+        override fun onUserAudioAvailable(userId: String?, isAudioAvailable: Boolean) {
+            Logger.info(TAG, "onUserAudioAvailable -> {userId: $userId, isAudioAvailable: $isAudioAvailable}")
+            if (userId.isNullOrEmpty()) {
+                return
+            }
+            val user = findUser(userId)
+            if (user != null && user.audioAvailable.get() != isAudioAvailable) {
+                user.audioAvailable.set(isAudioAvailable)
+            }
+        }
+
+        override fun onUserVoiceVolumeChanged(volumeMap: Map<String?, Int?>?) {
+            if (TUICallDefine.Scene.SINGLE_CALL == scene.get() || volumeMap.isNullOrEmpty()) {
+                return
+            }
+            for (entry in volumeMap.entries) {
+                if (null != entry && !TextUtils.isEmpty(entry.key)) {
+                    val user = findUser(entry.key)
+                    if (user != null && user.playoutVolume.get() != entry.value) {
+                        user.playoutVolume.set(entry.value)
+                    }
+                }
+            }
+        }
+
+        override fun onUserNetworkQualityChanged(networkQualityList: List<TUICommonDefine.NetworkQualityInfo?>?) {
+            if (networkQualityList.isNullOrEmpty()) {
+                return
+            }
+            val iterator = networkQualityList.iterator()
+            if (scene.get() == TUICallDefine.Scene.GROUP_CALL) {
+                while (iterator.hasNext()) {
+                    val info = iterator.next()
+                    val user = findUser(info?.userId)
+                    user?.networkQualityReminder?.set(isBadNetwork(info?.quality))
+                }
+            } else if (scene.get() == TUICallDefine.Scene.SINGLE_CALL) {
+                var localQuality: NetworkQuality? = NetworkQuality.UNKNOWN
+                var remoteQuality: NetworkQuality? = NetworkQuality.UNKNOWN
+
+                while (iterator.hasNext()) {
+                    val info = iterator.next()
+                    if (selfUser.get().id == info?.userId) {
+                        localQuality = info?.quality
+                    } else {
+                        remoteQuality = info?.quality
+                    }
+                }
+
+                if (isBadNetwork(localQuality)) {
+                    networkQualityReminder.set(Constants.NetworkQualityHint.Local)
+                } else if (isBadNetwork(remoteQuality)) {
+                    networkQualityReminder.set(Constants.NetworkQualityHint.Remote)
+                } else {
+                    networkQualityReminder.set(Constants.NetworkQualityHint.None)
+                }
+            }
+        }
+
+        override fun onKickedOffline() {
+            EngineManager.instance.hangup(null)
+            resetCall()
+        }
+
+        override fun onUserSigExpired() {
+            EngineManager.instance.hangup(null)
+            resetCall()
+        }
+    }
+
+    private fun isBadNetwork(quality: NetworkQuality?): Boolean {
+        return quality == NetworkQuality.BAD || quality == NetworkQuality.VERY_BAD || quality == NetworkQuality.DOWN
+    }
+
+    fun clear() {
+        Logger.info(TAG, "clear")
+        reverse1v1CallRenderView = false
+        isShowFullScreen.set(false)
+        isBottomViewExpand.set(true)
+        showLargeViewUserId.set(null)
+        enableBlurBackground.set(false)
+        networkQualityReminder.set(Constants.NetworkQualityHint.None)
+        selfUser.get().callStatus.set(TUICallDefine.Status.None)
+        selfUser.get().clear()
+        selfUser.set(User())
+        for (user in remoteUserList.get()) {
+            user.clear()
+        }
+        remoteUserList.set(LinkedHashSet())
+        scene.set(null)
+        mediaType.set(TUICallDefine.MediaType.Unknown)
+        timeCount.set(0)
+        roomId.set(null)
+        groupId.set(null)
+        isCameraOpen.set(false)
+        isFrontCamera.set(TUICommonDefine.Camera.Front)
+        isMicrophoneMute.set(false)
+        audioPlayoutDevice.set(AudioPlaybackDevice.Speakerphone)
+
+        selfUser.removeAll()
+        remoteUserList.removeAll()
+        scene.removeAll()
+        mediaType.removeAll()
+        timeCount.removeAll()
+        roomId.removeAll()
+        groupId.removeAll()
+        isCameraOpen.removeAll()
+        isFrontCamera.removeAll()
+        isMicrophoneMute.removeAll()
+        audioPlayoutDevice.removeAll()
+        isShowFullScreen.removeAll()
+        isBottomViewExpand.removeAll()
+        showLargeViewUserId.removeAll()
+        enableBlurBackground.removeAll()
+        networkQualityReminder.removeAll()
+
+        if (TUICore.getService(TUIConstants.USBCamera.SERVICE_NAME) != null) {
+            TUICore.notifyEvent(
+                TUIConstants.USBCamera.KEY_USB_CAMERA, TUIConstants.USBCamera.SUB_KEY_CLOSE_CAMERA, null
+            )
+        }
+    }
+
+    private fun resetCall() {
+        stopForegroundService()
+        stopTimeCount()
+        clear()
+    }
+
+    private fun startTimeCount() {
+        timeHandlerThread = HandlerThread("time-count-thread")
+        timeHandlerThread?.start()
+        timeHandler = Handler(timeHandlerThread!!.looper)
+
+        if (timeRunnable != null) {
+            return
+        }
+        timeCount.set(0)
+        timeRunnable = Runnable {
+            var count = timeCount.get() + 1
+            timeCount.set(count)
+            timeHandler?.postDelayed(timeRunnable!!, 1000)
+        }
+        timeHandler?.post(timeRunnable!!)
+    }
+
+    private fun stopTimeCount() {
+        if (timeHandler != null) {
+            timeHandler?.removeCallbacks(timeRunnable!!)
+            timeHandler = null
+        }
+        timeRunnable = null
+        timeCount.set(0)
+        if (timeHandlerThread != null) {
+            timeHandlerThread?.quitSafely()
+            timeHandlerThread = null
+        }
+    }
+
+    private fun findUser(userId: String?): User? {
+        if (TextUtils.isEmpty(userId)) {
+            return null
+        }
+        if (userId == selfUser.get().id) {
+            return selfUser.get()
+        } else {
+            for (user in remoteUserList.get()) {
+                if (null != user && !TextUtils.isEmpty(user.id) && userId == user.id) {
+                    return user
+                }
+            }
+        }
+        return null
+    }
+
+    private fun removeUserOnLeave(userId: String) {
+        val user = findUser(userId)
+        if (user == null || TextUtils.isEmpty(user.id)) {
+            return
+        }
+        user.clear()
+        if (selfUser != null && selfUser.get() != null && user.id == selfUser.get().id) {
+            selfUser.get().callStatus.set(TUICallDefine.Status.None)
+        }
+        if (remoteUserList != null && remoteUserList.get() != null && remoteUserList.get().contains(user)) {
+            remoteUserList.remove(user)
+        }
+    }
+
+    private fun updateUserOnEnter(userId: String) {
+        var user = findUser(userId)
+        if (user == null) {
+            user = User()
+            user.id = userId
+        }
+        if (selfUser.get().callStatus.get() != TUICallDefine.Status.Accept) {
+            selfUser.get().callStatus.set(TUICallDefine.Status.Accept)
+        }
+        user.callStatus.set(TUICallDefine.Status.Accept)
+        if (!remoteUserList.get().contains(user) && !userId.equals(selfUser.get().id)) {
+            remoteUserList.add(user)
+        }
+        if (TextUtils.isEmpty(user.nickname.get()) || TextUtils.isEmpty(user.avatar.get())) {
+            UserInfoUtils.updateUserInfo(user)
+        }
+    }
+
+    private fun startForegroundService() {
+        if (scene.get() == TUICallDefine.Scene.GROUP_CALL || mediaType.get() == TUICallDefine.MediaType.Video) {
+            VideoForegroundService.start(TUIConfig.getAppContext(), "", "", 0)
+        } else if (mediaType.get() == TUICallDefine.MediaType.Audio) {
+            AudioForegroundService.start(TUIConfig.getAppContext(), "", "", 0)
+        }
+    }
+
+    private fun stopForegroundService() {
+        VideoForegroundService.stop(TUIConfig.getAppContext())
+        AudioForegroundService.stop(TUIConfig.getAppContext())
+    }
+
+    companion object {
+        const val TAG = "TUICallState"
+        val instance: TUICallState = TUICallState()
+    }
+}

+ 251 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/BlurUtils.kt

@@ -0,0 +1,251 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.os.Build
+import android.renderscript.*
+
+object BlurUtils {
+    @Throws(RSRuntimeException::class)
+    fun rsbBlur(context: Context?, bitmap: Bitmap?, radius: Int): Bitmap? {
+        var rs: RenderScript? = null
+        var input: Allocation? = null
+        var output: Allocation? = null
+        var blur: ScriptIntrinsicBlur? = null
+        try {
+            rs = RenderScript.create(context)
+            rs.messageHandler = RenderScript.RSMessageHandler()
+            input = Allocation.createFromBitmap(
+                rs, bitmap, Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT
+            )
+            output = Allocation.createTyped(rs, input.type)
+            blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
+            blur.setRadius(radius.toFloat())
+            blur.setInput(input)
+            blur.forEach(output)
+            output.copyTo(bitmap)
+        } catch (e: Exception) {
+            e.printStackTrace()
+        } finally {
+            if (rs != null) {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+                    RenderScript.releaseAllContexts()
+                } else {
+                    rs.destroy()
+                }
+            }
+            input?.destroy()
+            output?.destroy()
+            blur?.destroy()
+        }
+        return bitmap
+    }
+
+    fun fastBlur(sentBitmap: Bitmap, radius: Int, canReuseInBitmap: Boolean): Bitmap? {
+        val bitmap: Bitmap = if (canReuseInBitmap) {
+            sentBitmap
+        } else {
+            sentBitmap.copy(sentBitmap.getConfig(), true)
+        }
+        if (radius < 1) {
+            return null
+        }
+        val w: Int = bitmap.getWidth()
+        val h: Int = bitmap.getHeight()
+        val pix = IntArray(w * h)
+        bitmap.getPixels(pix, 0, w, 0, 0, w, h)
+        val wm = w - 1
+        val hm = h - 1
+        val wh = w * h
+        val div = radius + radius + 1
+        val r = IntArray(wh)
+        val g = IntArray(wh)
+        val b = IntArray(wh)
+        var rsum: Int
+        var gsum: Int
+        var bsum: Int
+        var x: Int
+        var y: Int
+        var i: Int
+        var p: Int
+        var yp: Int
+        var yi: Int
+        var yw: Int
+        val vmin = IntArray(Math.max(w, h))
+        var divsum = div + 1 shr 1
+        divsum *= divsum
+        val dv = IntArray(256 * divsum)
+        i = 0
+        while (i < 256 * divsum) {
+            dv[i] = i / divsum
+            i++
+        }
+        yi = 0
+        yw = yi
+        val stack = Array(div) { IntArray(3) }
+        var stackpointer: Int
+        var stackstart: Int
+        var sir: IntArray
+        var rbs: Int
+        val r1 = radius + 1
+        var routsum: Int
+        var goutsum: Int
+        var boutsum: Int
+        var rinsum: Int
+        var ginsum: Int
+        var binsum: Int
+        y = 0
+        while (y < h) {
+            bsum = 0
+            gsum = bsum
+            rsum = gsum
+            boutsum = rsum
+            goutsum = boutsum
+            routsum = goutsum
+            binsum = routsum
+            ginsum = binsum
+            rinsum = ginsum
+            i = -radius
+            while (i <= radius) {
+                p = pix[yi + Math.min(wm, Math.max(i, 0))]
+                sir = stack[i + radius]
+                sir[0] = p and 0xff0000 shr 16
+                sir[1] = p and 0x00ff00 shr 8
+                sir[2] = p and 0x0000ff
+                rbs = r1 - Math.abs(i)
+                rsum += sir[0] * rbs
+                gsum += sir[1] * rbs
+                bsum += sir[2] * rbs
+                if (i > 0) {
+                    rinsum += sir[0]
+                    ginsum += sir[1]
+                    binsum += sir[2]
+                } else {
+                    routsum += sir[0]
+                    goutsum += sir[1]
+                    boutsum += sir[2]
+                }
+                i++
+            }
+            stackpointer = radius
+            x = 0
+            while (x < w) {
+                r[yi] = dv[rsum]
+                g[yi] = dv[gsum]
+                b[yi] = dv[bsum]
+                rsum -= routsum
+                gsum -= goutsum
+                bsum -= boutsum
+                stackstart = stackpointer - radius + div
+                sir = stack[stackstart % div]
+                routsum -= sir[0]
+                goutsum -= sir[1]
+                boutsum -= sir[2]
+                if (y == 0) {
+                    vmin[x] = Math.min(x + radius + 1, wm)
+                }
+                p = pix[yw + vmin[x]]
+                sir[0] = p and 0xff0000 shr 16
+                sir[1] = p and 0x00ff00 shr 8
+                sir[2] = p and 0x0000ff
+                rinsum += sir[0]
+                ginsum += sir[1]
+                binsum += sir[2]
+                rsum += rinsum
+                gsum += ginsum
+                bsum += binsum
+                stackpointer = (stackpointer + 1) % div
+                sir = stack[stackpointer % div]
+                routsum += sir[0]
+                goutsum += sir[1]
+                boutsum += sir[2]
+                rinsum -= sir[0]
+                ginsum -= sir[1]
+                binsum -= sir[2]
+                yi++
+                x++
+            }
+            yw += w
+            y++
+        }
+        x = 0
+        while (x < w) {
+            bsum = 0
+            gsum = bsum
+            rsum = gsum
+            boutsum = rsum
+            goutsum = boutsum
+            routsum = goutsum
+            binsum = routsum
+            ginsum = binsum
+            rinsum = ginsum
+            yp = -radius * w
+            i = -radius
+            while (i <= radius) {
+                yi = Math.max(0, yp) + x
+                sir = stack[i + radius]
+                sir[0] = r[yi]
+                sir[1] = g[yi]
+                sir[2] = b[yi]
+                rbs = r1 - Math.abs(i)
+                rsum += r[yi] * rbs
+                gsum += g[yi] * rbs
+                bsum += b[yi] * rbs
+                if (i > 0) {
+                    rinsum += sir[0]
+                    ginsum += sir[1]
+                    binsum += sir[2]
+                } else {
+                    routsum += sir[0]
+                    goutsum += sir[1]
+                    boutsum += sir[2]
+                }
+                if (i < hm) {
+                    yp += w
+                }
+                i++
+            }
+            yi = x
+            stackpointer = radius
+            y = 0
+            while (y < h) {
+                // Preserve alpha channel: ( 0xff000000 & pix[yi] )
+                pix[yi] = -0x1000000 and pix[yi] or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
+                rsum -= routsum
+                gsum -= goutsum
+                bsum -= boutsum
+                stackstart = stackpointer - radius + div
+                sir = stack[stackstart % div]
+                routsum -= sir[0]
+                goutsum -= sir[1]
+                boutsum -= sir[2]
+                if (x == 0) {
+                    vmin[y] = Math.min(y + r1, hm) * w
+                }
+                p = x + vmin[y]
+                sir[0] = r[p]
+                sir[1] = g[p]
+                sir[2] = b[p]
+                rinsum += sir[0]
+                ginsum += sir[1]
+                binsum += sir[2]
+                rsum += rinsum
+                gsum += ginsum
+                bsum += binsum
+                stackpointer = (stackpointer + 1) % div
+                sir = stack[stackpointer]
+                routsum += sir[0]
+                goutsum += sir[1]
+                boutsum += sir[2]
+                rinsum -= sir[0]
+                ginsum -= sir[1]
+                binsum -= sir[2]
+                yi += w
+                y++
+            }
+            x++
+        }
+        bitmap.setPixels(pix, 0, w, 0, 0, w, h)
+        return bitmap
+    }
+}

+ 62 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/DeviceUtils.kt

@@ -0,0 +1,62 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import android.app.ActivityManager
+import android.app.KeyguardManager
+import android.content.Context
+import android.os.Build
+import android.text.TextUtils
+import android.view.Window
+import android.view.WindowManager
+import com.tencent.qcloud.tuicore.util.TUIBuild
+
+object DeviceUtils {
+    fun setScreenLockParams(window: Window?) {
+        if (null == window) {
+            return
+        }
+        window.addFlags(
+            WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+                    or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
+                    or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+                    or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+        )
+    }
+
+    fun isScreenLocked(context: Context): Boolean {
+        val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+        return if (TUIBuild.getVersionInt() >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+            keyguardManager.isDeviceLocked()
+        } else {
+            keyguardManager.inKeyguardRestrictedInputMode()
+        }
+    }
+
+    fun isServiceRunning(context: Context?, className: String): Boolean {
+        if (context == null || TextUtils.isEmpty(className)) {
+            return false
+        }
+        val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+        val info = am.getRunningServices(0x7FFFFFFF)
+        if (info == null || info.size == 0) {
+            return false
+        }
+        for (aInfo in info) {
+            if (className == aInfo.service.className) {
+                return true
+            }
+        }
+        return false
+    }
+
+    fun isAppRunningForeground(context: Context): Boolean {
+        val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
+        val runningAppProcessInfos = activityManager?.runningAppProcesses ?: return false
+        val packageName = context.packageName
+        for (appProcessInfo in runningAppProcessInfos) {
+            if (appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcessInfo.processName == packageName) {
+                return true
+            }
+        }
+        return false
+    }
+}

+ 161 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/ImageLoader.kt

@@ -0,0 +1,161 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.BitmapShader
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.RenderEffect
+import android.graphics.Shader
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.widget.ImageView
+import androidx.annotation.DrawableRes
+import androidx.annotation.RawRes
+import com.bumptech.glide.Glide
+import com.bumptech.glide.RequestBuilder
+import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
+import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
+import com.bumptech.glide.request.RequestOptions
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import java.lang.ref.WeakReference
+import java.security.MessageDigest
+
+object ImageLoader {
+    private val radius: Int = 1
+
+    @JvmStatic
+    fun loadImage(context: Context?, imageView: ImageView?, url: Any?) {
+        loadImage(context, imageView, url, R.drawable.tuicallkit_ic_avatar)
+    }
+
+    @JvmStatic
+    fun loadImage(context: Context?, imageView: ImageView?, url: Any?, @DrawableRes errorResId: Int = 0) {
+        loadImage(context, imageView, url, errorResId, this.radius)
+    }
+
+    @JvmStatic
+    fun loadImage(
+        context: Context?, imageView: ImageView?, url: Any?, @DrawableRes errorResId: Int = 0,
+        radius: Int = this.radius
+    ) {
+        if (url == null) {
+            if (imageView != null && errorResId != 0) {
+                imageView.setImageResource(errorResId)
+            }
+            return
+        }
+        Glide.with(context!!.applicationContext).load(url)
+            .error(loadTransform(context.applicationContext, errorResId, radius)).into(imageView!!)
+    }
+
+    fun loadGifImage(context: Context?, imageView: ImageView?, @RawRes @DrawableRes resourceId: Int) {
+        Glide.with(context!!.applicationContext).asGif().load(resourceId).into(imageView!!)
+    }
+
+    fun loadBlurImage(context: Context?, imageView: ImageView?, url: Any?, radius: Float = 80f) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            loadImage(context, imageView, url, R.drawable.tuicallkit_ic_avatar)
+            imageView?.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.MIRROR))
+        } else {
+            Glide.with(context!!.applicationContext).load(url).error(R.drawable.tuicallkit_ic_avatar)
+                .apply(RequestOptions.bitmapTransform(BlurTransformation(context))).into(imageView!!)
+        }
+        imageView?.setColorFilter(Color.parseColor("#8022262E"))
+    }
+
+    @JvmStatic
+    fun clear(context: Context?, imageView: ImageView?) {
+        Glide.with(context!!).clear(imageView!!)
+    }
+
+    private fun loadTransform(
+        context: Context?, @DrawableRes placeholderId: Int, radius: Int
+    ): RequestBuilder<Drawable> {
+        return Glide.with(context!!).load(placeholderId)
+            .apply(RequestOptions().centerCrop().transform(GlideRoundTransform(context, radius)))
+    }
+
+    @JvmStatic
+    fun loadBitmap(context: Context, imgUrl: Any?, targetImageSize: Int): Bitmap? {
+        return if (imgUrl == null) {
+            null
+        } else Glide.with(context).asBitmap().load(imgUrl)
+            .apply(loadTransform(context, R.drawable.tuicallkit_ic_avatar, radius))
+            .into(targetImageSize, targetImageSize)
+            .get()
+    }
+
+    class GlideRoundTransform(context: Context?, dp: Int) : BitmapTransformation() {
+        override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
+            return roundCrop(pool, toTransform)!!
+        }
+
+        override fun updateDiskCacheKey(messageDigest: MessageDigest) {}
+
+        companion object {
+            private var radius = 0f
+            private fun roundCrop(pool: BitmapPool, source: Bitmap?): Bitmap? {
+                if (source == null) {
+                    return null
+                }
+                var result: Bitmap? = pool[source.width, source.height, Bitmap.Config.ARGB_8888]
+                if (result == null) {
+                    result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
+                }
+                val canvas = Canvas(result!!)
+                val paint = Paint()
+                paint.shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
+                paint.isAntiAlias = true
+                val rectF = RectF(
+                    0f, 0f, source.width.toFloat(), source.height
+                        .toFloat()
+                )
+                canvas.drawRoundRect(rectF, radius, radius, paint)
+                return result
+            }
+        }
+
+        init {
+            radius = Resources.getSystem().displayMetrics.density * dp
+        }
+    }
+
+    class BlurTransformation(context: Context) : BitmapTransformation() {
+        private var radius = 24
+        private var sampling = 10
+        private var weakPreference: WeakReference<Context>
+
+        init {
+            weakPreference = WeakReference(context.applicationContext)
+        }
+
+        override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+        }
+
+        override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap? {
+            val width = toTransform.width
+            val height = toTransform.height
+            val scaleWidth = width / sampling
+            val scaleHeight = height / sampling
+
+            var bitmap = pool.get(scaleWidth, scaleHeight, Bitmap.Config.ARGB_8888)
+            bitmap.density = toTransform.density
+
+            val canvas = Canvas(bitmap)
+            canvas.scale(1 / sampling.toFloat(), 1 / sampling.toFloat())
+            val paint = Paint()
+            paint.flags = Paint.FILTER_BITMAP_FLAG
+            canvas.drawBitmap(toTransform, 0f, 0f, paint)
+
+            try {
+                return BlurUtils.rsbBlur(weakPreference.get(), bitmap, radius)
+            } catch (e: Exception) {
+                return BlurUtils.fastBlur(toTransform, radius, true)
+            }
+        }
+    }
+}

+ 68 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/Logger.kt

@@ -0,0 +1,68 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import com.tencent.trtc.TRTCCloud
+import com.trtc.tuikit.common.system.ContextProvider
+import org.json.JSONException
+import org.json.JSONObject
+
+object Logger {
+    private const val API = "TuikitLog"
+    private const val LOG_KEY_API = "api"
+    private const val LOG_KEY_PARAMS = "params"
+    private const val LOG_KEY_PARAMS_LEVEL = "level"
+    private const val LOG_KEY_PARAMS_MESSAGE = "message"
+    private const val LOG_KEY_PARAMS_FILE = "file"
+    private const val LOG_KEY_PARAMS_MODULE = "module"
+    private const val LOG_KEY_PARAMS_LINE = "line"
+    private const val LOG_KEY_PARAMS_FILE_VALUE = "Logger"
+    private const val LOG_KEY_PARAMS_MODULE_VALUE = "TUICallKit"
+    private const val LOG_KEY_PARAMS_LINE_VALUE = 0
+    private const val LOG_LEVEL_INFO = 0
+    private const val LOG_LEVEL_WARNING = 1
+    private const val LOG_LEVEL_ERROR = 2
+
+    fun error(tag: String, message: String) {
+        error(tag, message, LOG_KEY_PARAMS_MODULE_VALUE, LOG_KEY_PARAMS_FILE_VALUE, LOG_KEY_PARAMS_LINE_VALUE)
+    }
+
+    fun warn(tag: String, message: String) {
+        warn(tag, message, LOG_KEY_PARAMS_MODULE_VALUE, LOG_KEY_PARAMS_FILE_VALUE, LOG_KEY_PARAMS_LINE_VALUE)
+    }
+
+    fun info(tag: String, message: String) {
+        info(tag, message, LOG_KEY_PARAMS_MODULE_VALUE, LOG_KEY_PARAMS_FILE_VALUE, LOG_KEY_PARAMS_LINE_VALUE)
+    }
+
+    private fun error(tag: String, message: String, module: String, file: String, line: Int) {
+        log(tag, message, LOG_LEVEL_ERROR, module, file, line)
+    }
+
+    private fun warn(tag: String, message: String, module: String, file: String, line: Int) {
+        log(tag, message, LOG_LEVEL_WARNING, module, file, line)
+    }
+
+    private fun info(tag: String, message: String, module: String, file: String, line: Int) {
+        log(tag, message, LOG_LEVEL_INFO, module, file, line)
+    }
+
+    private fun log(tag: String, message: String, level: Int, module: String, file: String, line: Int) {
+        val builder = StringBuilder().apply { append(tag).append(", ").append(message) }
+        try {
+            val paramsJson = JSONObject()
+            paramsJson.put(LOG_KEY_PARAMS_LEVEL, level)
+            paramsJson.put(LOG_KEY_PARAMS_MESSAGE, builder.toString())
+            paramsJson.put(LOG_KEY_PARAMS_MODULE, module)
+            paramsJson.put(LOG_KEY_PARAMS_FILE, file)
+            paramsJson.put(LOG_KEY_PARAMS_LINE, line)
+
+            val loggerJson = JSONObject()
+            loggerJson.put(LOG_KEY_API, API)
+            loggerJson.put(LOG_KEY_PARAMS, paramsJson)
+
+            TRTCCloud.sharedInstance(ContextProvider.getApplicationContext())
+                .callExperimentalAPI(loggerJson.toString())
+        } catch (e: JSONException) {
+            throw RuntimeException(e)
+        }
+    }
+}

+ 188 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/PermissionRequest.kt

@@ -0,0 +1,188 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import android.Manifest
+import android.app.AppOpsManager
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuicore.permission.PermissionRequester
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+object PermissionRequest {
+    fun requestPermissions(context: Context, type: TUICallDefine.MediaType, callback: PermissionCallback?) {
+        val title = StringBuilder().append(context.getString(R.string.tuicallkit_permission_microphone))
+        val reason = StringBuilder()
+        reason.append(getMicrophonePermissionHint(context))
+
+        val permissionList: MutableList<String> = ArrayList()
+        permissionList.add(Manifest.permission.RECORD_AUDIO)
+        if (TUICallDefine.MediaType.Video == type) {
+            title.append(context.getString(R.string.tuicallkit_permission_separator))
+            title.append(context.getString(R.string.tuicallkit_permission_camera))
+            reason.append(getCameraPermissionHint(context))
+            permissionList.add(Manifest.permission.CAMERA)
+        }
+
+        if (PermissionRequester.newInstance(*permissionList.toTypedArray()).has()) {
+            callback?.onGranted()
+            return
+        }
+
+        val permissionCallback: PermissionCallback = object : PermissionCallback() {
+            override fun onGranted() {
+                requestBluetoothPermission(context, object : PermissionCallback() {
+                    override fun onGranted() {
+                        callback?.onGranted()
+                    }
+                })
+            }
+
+            override fun onDenied() {
+                super.onDenied()
+                callback?.onDenied()
+            }
+        }
+        val applicationInfo = context.applicationInfo
+        val appName = context.packageManager.getApplicationLabel(applicationInfo).toString()
+        val permissions = permissionList.toTypedArray()
+        PermissionRequester.newInstance(*permissions)
+            .title(context.getString(R.string.tuicallkit_permission_title, appName, title))
+            .description(reason.toString())
+            .settingsTip("${context.getString(R.string.tuicallkit_permission_tips, title)} $reason".trimIndent())
+            .callback(permissionCallback)
+            .request()
+    }
+
+    fun requestCameraPermission(context: Context, callback: PermissionCallback?) {
+        if (PermissionRequester.newInstance(Manifest.permission.CAMERA).has()) {
+            callback?.onGranted()
+            return
+        }
+
+        val permissionCallback: PermissionCallback = object : PermissionCallback() {
+            override fun onGranted() {
+                callback?.onGranted()
+            }
+
+            override fun onDenied() {
+                super.onDenied()
+                callback?.onDenied()
+            }
+        }
+
+        val title = context.getString(R.string.tuicallkit_permission_camera)
+        val reason = getCameraPermissionHint(context)
+        val appName = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
+
+        PermissionRequester.newInstance(Manifest.permission.CAMERA)
+            .title(context.getString(R.string.tuicallkit_permission_title, appName, title))
+            .description(reason)
+            .settingsTip("${context.getString(R.string.tuicallkit_permission_tips, title)} $reason".trimIndent())
+            .callback(permissionCallback)
+            .request()
+    }
+
+    /**
+     * Android S(31) need apply for Nearby devices(Bluetooth) permission to support bluetooth headsets.
+     * Please refer to: https://developer.android.com/guide/topics/connectivity/bluetooth/permissions
+     */
+    private fun requestBluetoothPermission(context: Context, callback: PermissionCallback) {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+            callback.onGranted()
+            return
+        }
+        if (PermissionRequester.newInstance(Manifest.permission.BLUETOOTH_CONNECT).has()) {
+            callback.onGranted()
+            return
+        }
+
+        val title = context.getString(R.string.tuicallkit_permission_bluetooth)
+        val reason = context.getString(R.string.tuicallkit_permission_bluetooth_reason)
+        val applicationInfo = context.applicationInfo
+        val appName = context.packageManager.getApplicationLabel(applicationInfo).toString()
+        PermissionRequester.newInstance(Manifest.permission.BLUETOOTH_CONNECT)
+            .title(context.getString(R.string.tuicallkit_permission_title, appName, title))
+            .description(reason)
+            .settingsTip(reason)
+            .callback(object : PermissionCallback() {
+                override fun onGranted() {
+                    callback.onGranted()
+                }
+
+                override fun onDenied() {
+                    super.onDenied()
+                    //bluetooth is unnecessary permission, return permission granted
+                    callback.onGranted()
+                }
+            })
+            .request()
+    }
+
+    fun requestFloatPermission(context: Context?) {
+        if (PermissionRequester.newInstance(PermissionRequester.FLOAT_PERMISSION).has()) {
+            return
+        }
+        //In TUICallKit,Please open both OverlayWindows and Background pop-ups permission.
+        PermissionRequester.newInstance(PermissionRequester.FLOAT_PERMISSION, PermissionRequester.BG_START_PERMISSION)
+            .request()
+    }
+
+    private fun getMicrophonePermissionHint(context: Context): String {
+        val microphonePermissionsDescription = TUICore.createObject(
+            TUIConstants.Privacy.PermissionsFactory.FACTORY_NAME,
+            TUIConstants.Privacy.PermissionsFactory.PermissionsName.MICROPHONE_PERMISSIONS, null
+        ) as String?
+        return if (!microphonePermissionsDescription.isNullOrEmpty()) {
+            microphonePermissionsDescription
+        } else {
+            context.getString(R.string.tuicallkit_permission_mic_reason)
+        }
+    }
+
+    private fun getCameraPermissionHint(context: Context): String {
+        val cameraPermissionsDescription = TUICore.createObject(
+            TUIConstants.Privacy.PermissionsFactory.FACTORY_NAME,
+            TUIConstants.Privacy.PermissionsFactory.PermissionsName.CAMERA_PERMISSIONS, null
+        ) as String?
+        return if (!cameraPermissionsDescription.isNullOrEmpty()) {
+            cameraPermissionsDescription
+        } else {
+            context.getString(R.string.tuicallkit_permission_camera_reason)
+        }
+    }
+
+    fun isNotificationEnabled(): Boolean {
+        val context = TUIConfig.getAppContext()
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            // For Android Oreo and above
+            val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+            return manager.areNotificationsEnabled()
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+            // For versions prior to Android Oreo
+            var appOps: AppOpsManager? = null
+            appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
+            val appInfo = context.applicationInfo
+            val packageName = context.applicationContext.packageName
+            val uid = appInfo.uid
+            try {
+                var appOpsClass: Class<*>? = null
+                appOpsClass = Class.forName(AppOpsManager::class.java.name)
+                val checkOpNoThrowMethod = appOpsClass.getMethod(
+                    "checkOpNoThrow", Integer.TYPE, Integer.TYPE, String::class.java
+                )
+                val opPostNotificationValue = appOpsClass.getDeclaredField("OP_POST_NOTIFICATION")
+                val value = opPostNotificationValue[Int::class.java] as Int
+                return checkOpNoThrowMethod.invoke(appOps, value, uid, packageName) as Int == AppOpsManager.MODE_ALLOWED
+            } catch (e: Exception) {
+                e.printStackTrace()
+            }
+        }
+        return false
+    }
+}

+ 128 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/utils/UserInfoUtils.kt

@@ -0,0 +1,128 @@
+package com.tencent.qcloud.tuikit.tuicallkit.utils
+
+import android.text.TextUtils
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.ValueCallback
+import com.tencent.imsdk.v2.V2TIMFriendInfoResult
+import com.tencent.imsdk.v2.V2TIMManager
+import com.tencent.imsdk.v2.V2TIMValueCallback
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+
+object UserInfoUtils {
+
+    private const val TAG = "UserInfoUtils"
+
+    fun updateUserInfo(user: User) {
+        if (TextUtils.isEmpty(user.id)) {
+            Logger.error(TAG, "getUsersInfo, user.userId isEmpty")
+            return
+        }
+        if (!TextUtils.isEmpty(user.nickname.get()) && !TextUtils.isEmpty(user.avatar.get())) {
+            Logger.info(TAG, "getUsersInfo, user.userName = ${user.nickname}, avatar = ${user.avatar}")
+            return
+        }
+        val userList: MutableList<String> = ArrayList()
+        userList.add(user.id!!)
+
+        getFriendsInfo(userList, object : ValueCallback<List<UserInfo?>?> {
+            override fun onSuccess(data: List<UserInfo?>?) {
+                if (data.isNullOrEmpty() || data[0] == null) {
+                    Logger.error(TAG, "getUserInfo result is empty")
+                    return
+                }
+                var userInfo = data[0]
+                Logger.info(
+                    TAG, "getUsersInfo -> userId:${user.id} onSuccess: " +
+                            "nickname:${userInfo?.nickname}, avatar:${userInfo?.avatar}" +
+                            ", friendRemark:${userInfo?.remark}"
+                )
+                if (!TextUtils.isEmpty(userInfo?.remark)) {
+                    user.nickname.set(userInfo?.remark)
+                } else if (!TextUtils.isEmpty(userInfo?.nickname)) {
+                    user.nickname.set(userInfo?.nickname)
+                } else {
+                    user.nickname.set(userInfo?.id)
+                }
+                user.avatar.set(userInfo?.avatar)
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                Logger.error(TAG, "getUsersInfo,userId:${user.id} error, errCode: $errCode , errMsg: $errMsg")
+            }
+
+        })
+    }
+
+    fun getUserListInfo(userList: List<String>, callback: ValueCallback<List<User>?>) {
+        getFriendsInfo(userList, object : ValueCallback<List<UserInfo?>?> {
+            override fun onSuccess(data: List<UserInfo?>?) {
+                if (data.isNullOrEmpty()) {
+                    Logger.error(TAG, "getUserInfo result is empty")
+                    return
+                }
+
+                var userList: MutableList<User> = ArrayList()
+                for (i in data.indices) {
+                    var userInfo = data[i]
+
+                    var user = User()
+                    user.id = userInfo?.id
+                    if (!TextUtils.isEmpty(userInfo?.remark)) {
+                        user.nickname.set(userInfo?.remark)
+                    } else if (!TextUtils.isEmpty(userInfo?.nickname)) {
+                        user.nickname.set(userInfo?.nickname)
+                    } else {
+                        user.nickname.set(userInfo?.id)
+                    }
+                    user.avatar.set(userInfo?.avatar)
+                    userList.add(user)
+                }
+                callback.onSuccess(userList)
+            }
+
+            override fun onError(errCode: Int, errMsg: String?) {
+                Logger.error(TAG, "getUsersInfo, userId:${userList} error, errCode: $errCode , errMsg: $errMsg")
+                callback.onError(errCode, errMsg)
+            }
+
+        })
+    }
+
+    private fun getFriendsInfo(
+        userList: List<String?>?,
+        callback: ValueCallback<List<UserInfo?>?>?
+    ) {
+        V2TIMManager.getFriendshipManager()
+            .getFriendsInfo(userList, object : V2TIMValueCallback<List<V2TIMFriendInfoResult?>?> {
+                override fun onSuccess(list: List<V2TIMFriendInfoResult?>?) {
+                    if (list.isNullOrEmpty()) {
+                        Logger.error(TAG, "getUserInfo result is empty")
+                        return
+                    }
+
+                    var userList: MutableList<UserInfo> = ArrayList()
+                    for (i in list.indices) {
+                        var friendInfo = list[i]?.friendInfo
+
+                        var userInfo = UserInfo()
+                        userInfo.id = friendInfo?.userID ?: ""
+                        userInfo.remark = friendInfo?.friendRemark ?: ""
+                        userInfo.nickname = friendInfo?.userProfile?.nickName ?: ""
+                        userInfo.avatar = friendInfo?.userProfile?.faceUrl ?: ""
+                        userList.add(userInfo)
+                    }
+                    callback?.onSuccess(userList)
+                }
+
+                override fun onError(code: Int, desc: String?) {
+                    callback?.onError(code, desc)
+                }
+            })
+    }
+
+    class UserInfo {
+        var id: String = ""
+        var avatar: String = ""
+        var nickname: String = ""
+        var remark: String = ""
+    }
+}

+ 202 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/CallKitActivity.kt

@@ -0,0 +1,202 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view
+
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.ActivityInfo
+import android.graphics.Color
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.FrameLayout
+import android.widget.RelativeLayout
+import androidx.appcompat.app.AppCompatActivity
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.DeviceUtils
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatWindowService
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.GroupCallView
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.SingleCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class CallKitActivity : AppCompatActivity() {
+    private var baseCallView: RelativeLayout? = null
+    private var layoutContainer: FrameLayout? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.None) {
+            Logger.info(TAG, "callStatusObserver None -> finishActivity")
+            finishActivity()
+            VideoViewFactory.instance.clear()
+            if (TUICallDefine.Status.None == TUICallState.instance.selfUser.get().callStatus.get()) {
+                FloatWindowService.stopService()
+            }
+        }
+    }
+
+    private var isShowFullScreenObserver = Observer<Boolean> {
+        if (it) {
+            window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+        } else {
+            window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        Logger.info(TAG, "onCreate")
+        DeviceUtils.setScreenLockParams(window)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+            setShowWhenLocked(true)
+            setTurnScreenOn(true)
+        }
+        activity = this
+        setContentView(R.layout.tuicallkit_activity_call_kit)
+        initStatusBar()
+        addObserver()
+
+        requestedOrientation = when (TUICallState.instance.orientation) {
+            Constants.Orientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+            Constants.Orientation.LandScape -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+            else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+        }
+    }
+
+    override fun onResume() {
+        super.onResume()
+        Logger.info(TAG, "onResume")
+        if (TUICallDefine.Status.None == TUICallState.instance.selfUser.get().callStatus.get()) {
+            finishActivity()
+            return
+        }
+        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?
+        notificationManager?.cancelAll()
+
+        if (DeviceUtils.isServiceRunning(application, FloatWindowService::class.java.getName())) {
+            FloatWindowService.stopService()
+        }
+        TUICore.notifyEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FULL_VIEW, HashMap())
+
+        PermissionRequest.requestPermissions(application, TUICallState.instance.mediaType.get(),
+            object : PermissionCallback() {
+                override fun onGranted() {
+                    initView()
+                    startActivityByAction()
+                }
+
+                override fun onDenied() {
+                    if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Called) {
+                        EngineManager.instance.reject(null)
+                    }
+                }
+            })
+    }
+
+    private fun startActivityByAction() {
+        if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Accept) {
+            return
+        }
+        if (intent.action == Constants.ACCEPT_CALL_ACTION) {
+            Logger.info(TAG, "IncomingView -> startActivityByAction")
+            EngineManager.instance.accept(null)
+            if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Video) {
+                val videoView = VideoViewFactory.instance.createVideoView(
+                    TUICallState.instance.selfUser.get(), application
+                )
+
+                EngineManager.instance.openCamera(
+                    TUICallState.instance.isFrontCamera.get(), videoView?.getVideoView(), null
+                )
+            }
+        }
+    }
+
+    override fun onBackPressed() {}
+
+    override fun onDestroy() {
+        super.onDestroy()
+        Logger.info(TAG, "onDestroy")
+    }
+
+    private fun initStatusBar() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            val window = window
+            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+            window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+                    or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
+            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+            window.statusBarColor = Color.TRANSPARENT
+            val lp = window.getAttributes();
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+                lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+            }
+            window.setAttributes(lp);
+        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+            window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+        }
+    }
+
+    private fun initView() {
+        layoutContainer = findViewById(R.id.rl_container)
+        layoutContainer?.removeAllViews()
+        if (baseCallView != null && baseCallView?.parent != null) {
+            (baseCallView?.parent as ViewGroup).removeView(baseCallView)
+        }
+
+        when (TUICallState.instance.scene.get()) {
+            TUICallDefine.Scene.SINGLE_CALL -> {
+                baseCallView = SingleCallView(applicationContext)
+                layoutContainer?.addView(baseCallView)
+            }
+
+            TUICallDefine.Scene.GROUP_CALL -> {
+                baseCallView = GroupCallView(applicationContext)
+                layoutContainer?.addView(baseCallView)
+            }
+
+            else -> {
+                Logger.warn(TAG, "current scene is invalid")
+                finishActivity()
+            }
+        }
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICallState.instance.isShowFullScreen.observe(isShowFullScreenObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICallState.instance.isShowFullScreen.removeObserver(isShowFullScreenObserver)
+    }
+
+    companion object {
+        private var activity: CallKitActivity? = null
+        private const val TAG = "CallKitActivity"
+
+        fun finishActivity() {
+            if (null != activity?.baseCallView && null != activity?.baseCallView?.parent) {
+                (activity?.baseCallView?.parent as ViewGroup).removeView(activity?.baseCallView)
+            }
+            if (null != activity?.baseCallView && activity?.baseCallView is GroupCallView) {
+                (activity?.baseCallView as GroupCallView).clear()
+            }
+            if (null != activity?.baseCallView && activity?.baseCallView is SingleCallView) {
+                (activity?.baseCallView as SingleCallView).clear()
+            }
+            activity?.removeObserver()
+            activity?.finish()
+            activity = null
+        }
+    }
+}

+ 110 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/CustomLoadingView.kt

@@ -0,0 +1,110 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AlphaAnimation
+import android.view.animation.Animation
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+class CustomLoadingView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
+    private var leftDot: ImageView
+    private var centerDot: ImageView
+    private var rightDot: ImageView
+
+    private var isLoading = false
+
+    init {
+        this.orientation = HORIZONTAL
+        this.gravity = Gravity.CENTER
+        this.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        leftDot = createImageView()
+        centerDot = createImageView()
+        rightDot = createImageView()
+        addView(leftDot)
+        addView(centerDot)
+        addView(rightDot)
+    }
+
+    private fun createImageView(): ImageView {
+        val lp = LayoutParams(24, 24)
+        lp.marginEnd = 24
+        val imageView = ImageFilterView(context)
+        imageView.layoutParams = lp
+        imageView.roundPercent = 1f
+        imageView.setBackgroundColor(resources.getColor(R.color.tuicallkit_color_white))
+        return imageView
+    }
+
+    private fun createAnimation(startAlpha: Float, endAlpha: Float): Animation {
+        val animation = AlphaAnimation(startAlpha, endAlpha)
+        animation.duration = DURATION
+        return animation
+    }
+
+    private fun startAnimation1() {
+        startAnimation(leftDot, 1.0f, 0.2f)
+        centerDot.startAnimation(createAnimation(0.6f, 1.0f))
+        rightDot.startAnimation(createAnimation(0.2f, 0.6f))
+    }
+
+    private fun startAnimation2() {
+        leftDot.startAnimation(createAnimation(0.2f, 0.2f))
+        startAnimation(centerDot, 1.0f, 0.6f)
+        rightDot.startAnimation(createAnimation(0.6f, 1.0f))
+    }
+
+    private fun startAnimation3() {
+        leftDot.startAnimation(createAnimation(0.2f, 0.2f))
+        centerDot.startAnimation(createAnimation(0.6f, 0.6f))
+        startAnimation(rightDot, 1.0f, 0.6f)
+    }
+
+    private fun startAnimation4() {
+        startAnimation(leftDot, 0.2f, 1.0f)
+        centerDot.startAnimation(createAnimation(0.6f, 0.6f))
+        rightDot.startAnimation(createAnimation(0.6f, 0.2f))
+    }
+
+    private fun startAnimation(view: View?, fromAlpha: Float, toAlpha: Float) {
+        val animation = createAnimation(fromAlpha, toAlpha)
+        animation.setAnimationListener(object : Animation.AnimationListener {
+            override fun onAnimationStart(animation: Animation) {}
+            override fun onAnimationEnd(animation: Animation) {
+                if (!isLoading) {
+                    return
+                }
+                when {
+                    view === leftDot && fromAlpha > toAlpha -> startAnimation2()
+                    view === leftDot && fromAlpha < toAlpha -> startAnimation1()
+                    view === centerDot -> startAnimation3()
+                    view === rightDot -> startAnimation4()
+                }
+            }
+
+            override fun onAnimationRepeat(animation: Animation) {}
+        })
+        view!!.startAnimation(animation)
+    }
+
+    fun startLoading() {
+        isLoading = true
+        startAnimation1()
+    }
+
+    fun stopLoading() {
+        isLoading = false
+        leftDot.clearAnimation()
+        centerDot.clearAnimation()
+        rightDot.clearAnimation()
+    }
+
+    companion object {
+        private const val DURATION: Long = 500
+    }
+}

+ 98 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/RoundCornerImageView.kt

@@ -0,0 +1,98 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.PaintFlagsDrawFilter
+import android.graphics.Path
+import android.graphics.RectF
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+open class RoundCornerImageView : AppCompatImageView {
+    private var leftTopRadius = 0
+    private var rightTopRadius = 0
+    private var rightBottomRadius = 0
+    private var leftBottomRadius = 0
+    private val path = Path()
+    private val rectF = RectF()
+    private var radius = 0
+    private val aliasFilter = PaintFlagsDrawFilter(
+        0,
+        Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG
+    )
+
+    constructor(context: Context) : super(context) {
+        init(context, null)
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
+        init(context, attrs)
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
+
+    private fun init(context: Context, attrs: AttributeSet?) {
+        setLayerType(LAYER_TYPE_HARDWARE, null)
+        val defaultRadius = 0
+        if (attrs != null) {
+            val array = context.obtainStyledAttributes(attrs, R.styleable.RoundCornerImageView)
+            radius = array.getDimensionPixelOffset(R.styleable.RoundCornerImageView_corner_radius, defaultRadius)
+            leftTopRadius =
+                array.getDimensionPixelOffset(R.styleable.RoundCornerImageView_left_top_radius, defaultRadius)
+            rightTopRadius =
+                array.getDimensionPixelOffset(R.styleable.RoundCornerImageView_right_top_radius, defaultRadius)
+            rightBottomRadius =
+                array.getDimensionPixelOffset(R.styleable.RoundCornerImageView_right_bottom_radius, defaultRadius)
+            leftBottomRadius =
+                array.getDimensionPixelOffset(R.styleable.RoundCornerImageView_left_bottom_radius, defaultRadius)
+            array.recycle()
+        }
+        if (defaultRadius == leftTopRadius) {
+            leftTopRadius = radius
+        }
+        if (defaultRadius == rightTopRadius) {
+            rightTopRadius = radius
+        }
+        if (defaultRadius == rightBottomRadius) {
+            rightBottomRadius = radius
+        }
+        if (defaultRadius == leftBottomRadius) {
+            leftBottomRadius = radius
+        }
+    }
+
+    fun setRadius(radius: Int) {
+        this.radius = radius
+        leftBottomRadius = radius
+        rightBottomRadius = radius
+        rightTopRadius = radius
+        leftTopRadius = radius
+    }
+
+    fun getRadius(): Int {
+        return radius
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        path.reset()
+        canvas.drawFilter = aliasFilter
+        rectF[0f, 0f, measuredWidth.toFloat()] = measuredHeight.toFloat()
+        // left-top -> right-top -> right-bottom -> left-bottom
+        // left-top -> right-top -> right-bottom -> left-bottom
+        val radius = floatArrayOf(
+            leftTopRadius.toFloat(),
+            leftTopRadius.toFloat(),
+            rightTopRadius.toFloat(),
+            rightTopRadius.toFloat(),
+            rightBottomRadius.toFloat(),
+            rightBottomRadius.toFloat(),
+            leftBottomRadius.toFloat(),
+            leftBottomRadius.toFloat()
+        )
+        path.addRoundRect(rectF, radius, Path.Direction.CW)
+        canvas.clipPath(path)
+        super.onDraw(canvas)
+    }
+}

+ 134 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/RoundShadowLayout.kt

@@ -0,0 +1,134 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common
+
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.BitmapDrawable
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import com.tencent.qcloud.tuikit.tuicallkit.R
+
+class RoundShadowLayout(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
+    private val radiusArray = FloatArray(8)
+
+    private var shadowPaint: Paint
+    private var shadowRect: RectF
+    private var shadowPath: Path
+    private var shadowRadius = 15f
+    private var shadowColor = 0
+    private var shadowX = 0f
+    private var shadowY = 0f
+
+    private var roundRadius = 32f
+    private var roundPaint: Paint
+    private var roundRect: RectF
+    private var roundPath: Path
+
+    constructor(context: Context) : this(context, null) {}
+
+    init {
+        shadowColor = context.resources.getColor(R.color.tuicallkit_color_bg_float_view)
+
+        for (i in radiusArray.indices) {
+            radiusArray[i] = roundRadius
+        }
+
+        roundPaint = Paint()
+        roundPath = Path()
+        roundRect = RectF()
+
+        shadowRect = RectF()
+        shadowPath = Path()
+        shadowPaint = Paint()
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        var width = 0
+        var height = 0
+        for (i in 0 until childCount) {
+            val child = getChildAt(i)
+            val lp = child.layoutParams
+            val childWidthSpec = getChildMeasureSpec(widthMeasureSpec - shadowRadius.toInt() * 2, 0, lp.width)
+            val childHeightSpec = getChildMeasureSpec(heightMeasureSpec - shadowRadius.toInt() * 2, 0, lp.height)
+            measureChild(child, childWidthSpec, childHeightSpec)
+
+            val mlp = child.layoutParams as MarginLayoutParams
+            val childWidth = child.measuredWidth + mlp.leftMargin + mlp.rightMargin
+            val childHeight = child.measuredHeight + mlp.topMargin + mlp.bottomMargin
+            width = width.coerceAtLeast(childWidth)
+            height = height.coerceAtLeast(childHeight)
+        }
+        setMeasuredDimension(
+            width + paddingLeft + paddingRight + shadowRadius.toInt() * 2,
+            height + paddingTop + paddingBottom + shadowRadius.toInt() * 2
+        )
+    }
+
+    override fun onSizeChanged(width: Int, height: Int, oldw: Int, oldh: Int) {
+        super.onSizeChanged(width, height, oldw, oldh)
+        if (width > 0 && height > 0 && shadowRadius > 0) {
+            setBackgroundCompat(width, height)
+        }
+    }
+
+    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+        for (i in 0 until childCount) {
+            val child = getChildAt(i)
+            val lp = child.layoutParams as MarginLayoutParams
+            val lc = shadowRadius.toInt() + lp.leftMargin + paddingLeft
+            val tc = shadowRadius.toInt() + lp.topMargin + paddingTop
+            val rc = lc + child.measuredWidth
+            val bc = tc + child.measuredHeight
+            child.layout(lc, tc, rc, bc)
+        }
+    }
+
+    override fun dispatchDraw(canvas: Canvas) {
+        roundRect[shadowRadius, shadowRadius, width - shadowRadius] = height - shadowRadius
+        canvas!!.saveLayer(roundRect, null, Canvas.ALL_SAVE_FLAG)
+        super.dispatchDraw(canvas)
+        roundPath.reset()
+        roundPath.addRoundRect(roundRect, radiusArray, Path.Direction.CW)
+
+        clipRound(canvas)
+        canvas.restore()
+    }
+
+    private fun clipRound(canvas: Canvas) {
+        roundPaint.color = Color.WHITE
+        roundPaint.isAntiAlias = true
+        roundPaint.style = Paint.Style.FILL
+        roundPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
+        val path = Path()
+        path.addRect(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
+        path.op(roundPath, Path.Op.DIFFERENCE)
+        canvas.drawPath(path, roundPaint)
+    }
+
+    private fun setBackgroundCompat(width: Int, height: Int) {
+        val bitmap: Bitmap = createShadowBitmap(width, height, shadowRadius, shadowX, shadowY, shadowColor)
+        val drawable = BitmapDrawable(resources, bitmap)
+        background = drawable
+    }
+
+    private fun createShadowBitmap(
+        shadowWidth: Int, shadowHeight: Int, shadowRadius: Float, dx: Float, dy: Float, shadowColor: Int
+    ): Bitmap {
+        val output: Bitmap = Bitmap.createBitmap(shadowWidth, shadowHeight, Bitmap.Config.ARGB_8888)
+        val canvas = Canvas(output)
+        shadowRect[shadowRadius, shadowRadius, shadowWidth - shadowRadius] = shadowHeight - shadowRadius
+        shadowRect.top += dy
+        shadowRect.bottom -= dy
+        shadowRect.left += dx
+        shadowRect.right -= dx
+        shadowPaint.isAntiAlias = true
+        shadowPaint.style = Paint.Style.FILL
+        shadowPaint.color = shadowColor
+        if (!isInEditMode) {
+            shadowPaint.setShadowLayer(shadowRadius, dx, dy, shadowColor)
+        }
+        shadowPath.reset()
+        shadowPath.addRoundRect(shadowRect, radiusArray, Path.Direction.CW)
+        canvas.drawPath(shadowPath, shadowPaint)
+        return output
+    }
+}

+ 234 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/SlideRecyclerView.kt

@@ -0,0 +1,234 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common
+
+import android.content.Context
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.VelocityTracker
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewGroup
+import android.widget.Scroller
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kotlin.math.abs
+
+class SlideRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
+    RecyclerView(context, attrs, defStyle) {
+    private var flingView: ViewGroup? = null
+    private var velocityTracker: VelocityTracker? = null
+    private val scroller: Scroller
+    private var touchFrame: Rect? = null
+    private var lastX: Float = 0f
+    private var firstX: Float = 0f
+    private var firstY: Float = 0f
+    private val touchSlop: Int
+    private var isSlide = false
+    private var position: Int = 0
+    private var menuViewWidth: Int = 0
+    private var disableRecyclerViewSlide: Boolean = false
+
+    init {
+        touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+        scroller = Scroller(context)
+    }
+
+    fun disableRecyclerViewSlide(disable: Boolean) {
+        disableRecyclerViewSlide = disable
+    }
+
+    override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
+        val x = e.x.toInt()
+        val y = e.y.toInt()
+        obtainVelocity(e)
+        when (e.action) {
+            MotionEvent.ACTION_DOWN -> {
+                if (!scroller.isFinished) {
+                    scroller.abortAnimation()
+                }
+                run {
+                    lastX = x.toFloat()
+                    firstX = lastX
+                }
+                firstY = y.toFloat()
+                position = pointToPosition(x, y)
+                if (position != INVALID_POSITION) {
+                    val view: View? = flingView
+                    flingView = getChildAt(
+                        position - (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
+                    ) as ViewGroup
+                    if (view != null && flingView !== view && view.scrollX != 0) {
+                        view.scrollTo(0, 0)
+                    }
+                    menuViewWidth = if (flingView?.childCount == 2) {
+                        flingView?.getChildAt(1)?.width ?: INVALID_CHILD_WIDTH
+                    } else {
+                        INVALID_CHILD_WIDTH
+                    }
+                }
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                velocityTracker?.computeCurrentVelocity(1000)
+                val xVelocity = (if (velocityTracker?.xVelocity != null) velocityTracker?.xVelocity else 0f) as Float
+                val yVelocity = (if (velocityTracker?.yVelocity != null) velocityTracker?.yVelocity else 0f) as Float
+
+                val topView = (layoutManager as LinearLayoutManager?)?.findViewByPosition(0)
+                if (topView === flingView) {
+                    isSlide = false
+                } else if (abs(xVelocity) > SNAP_VELOCITY && abs(xVelocity) > abs(yVelocity)
+                    || abs(x - firstX) >= touchSlop
+                    && abs(x - firstX) > abs(y - firstY)
+                ) {
+                    isSlide = true
+                    return true
+                }
+            }
+
+            MotionEvent.ACTION_UP -> releaseVelocity()
+            else -> {}
+        }
+        return super.onInterceptTouchEvent(e)
+    }
+
+    override fun onTouchEvent(e: MotionEvent): Boolean {
+        if (isSlide && position != INVALID_POSITION) {
+            val x = e.x
+            obtainVelocity(e)
+            when (e.action) {
+                MotionEvent.ACTION_DOWN -> {}
+                MotionEvent.ACTION_MOVE ->
+                    flingView?.let {
+                        if (menuViewWidth != INVALID_CHILD_WIDTH) {
+                            val dx = Math.abs(it.scrollX + (lastX - x))
+                            if (dx <= menuViewWidth && dx > 0) {
+                                if (menuViewWidth != INVALID_CHILD_WIDTH) {
+                                    val scrollX = it.scrollX
+                                    velocityTracker?.computeCurrentVelocity(1000)
+                                    if (isRTL) {
+                                        openRightExtendView(scrollX)
+                                    } else {
+                                        openLeftExtendView(scrollX)
+                                    }
+                                    invalidate()
+                                }
+                                menuViewWidth = INVALID_CHILD_WIDTH
+                                isSlide = false
+                                position = INVALID_POSITION
+                            }
+                            lastX = x
+                        }
+                    }
+                MotionEvent.ACTION_UP -> releaseVelocity()
+                else -> {}
+            }
+            return true
+        } else {
+            closeMenu()
+            releaseVelocity()
+        }
+        return super.onTouchEvent(e)
+    }
+
+    private fun openRightExtendView(scrollX: Int) {
+        velocityTracker?.let {
+            if (it.xVelocity >= SNAP_VELOCITY) {
+                startScroll(scrollX, scrollX - menuViewWidth)
+            } else if (it.xVelocity < -SNAP_VELOCITY) {
+                startScroll(scrollX, -scrollX)
+            } else if (scrollX >= menuViewWidth / 2) {
+                startScroll(scrollX, scrollX - menuViewWidth)
+            } else {
+                startScroll(scrollX, -scrollX)
+            }
+        }
+    }
+
+    private fun openLeftExtendView(scrollX: Int) {
+        velocityTracker?.let {
+            if (it.xVelocity < -SNAP_VELOCITY) {
+                startScroll(scrollX, menuViewWidth - scrollX)
+            } else if (it.xVelocity >= SNAP_VELOCITY) {
+                startScroll(scrollX, -scrollX)
+            } else if (scrollX >= menuViewWidth / 2) {
+                startScroll(scrollX, menuViewWidth - scrollX)
+            } else {
+                startScroll(scrollX, -scrollX)
+            }
+        }
+    }
+
+    private fun startScroll(startX: Int, dx: Int) {
+        scroller.startScroll(startX, 0, dx, 0)
+    }
+
+    private fun releaseVelocity() {
+        if (velocityTracker != null) {
+            velocityTracker?.clear()
+            velocityTracker?.recycle()
+            velocityTracker = null
+        }
+    }
+
+    private fun obtainVelocity(event: MotionEvent) {
+        if (velocityTracker == null) {
+            velocityTracker = VelocityTracker.obtain()
+        }
+        velocityTracker?.addMovement(event)
+    }
+
+    fun pointToPosition(x: Int, y: Int): Int {
+        val firstPosition = (layoutManager as LinearLayoutManager?)?.findFirstVisibleItemPosition()
+        firstPosition?.let {
+            var frame = touchFrame
+            if (frame == null) {
+                touchFrame = Rect()
+                frame = touchFrame
+            }
+            val count = childCount
+            for (i in count - 1 downTo 0) {
+                val child = getChildAt(i)
+                if (child.visibility == View.VISIBLE) {
+                    child.getHitRect(frame)
+                    frame?.let {
+                        if (it.contains(x, y)) {
+                            return firstPosition + i
+                        }
+                    }
+                }
+            }
+        }
+        return INVALID_POSITION
+    }
+
+    override fun computeScroll() {
+        if (scroller.computeScrollOffset()) {
+            if (disableRecyclerViewSlide) {
+                flingView?.scrollTo(0, 0)
+            } else {
+                flingView?.scrollTo(scroller.currX, scroller.currY)
+            }
+            invalidate()
+        }
+    }
+
+    fun closeMenu() {
+        if (flingView != null && flingView?.scrollX != 0) {
+            flingView?.scrollTo(0, 0)
+        }
+    }
+
+    private val isRTL: Boolean
+        private get() {
+            val configuration = context.resources.configuration
+            val layoutDirection = configuration.layoutDirection
+            return layoutDirection == View.LAYOUT_DIRECTION_RTL
+        }
+
+    companion object {
+        private const val TAG = "SlideRecyclerView"
+        private const val INVALID_POSITION = -1
+        private const val INVALID_CHILD_WIDTH = -1
+        private const val SNAP_VELOCITY = 600
+    }
+}

+ 67 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/GridImageData.kt

@@ -0,0 +1,67 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import java.lang.Integer.min
+
+class GridImageData : Cloneable {
+
+    var imageUrlList: List<Any?>? = null
+    var bitmapMap: MutableMap<Int, Bitmap?>? = null
+    var defaultImageResId: Int = 0
+    var bgColor: Int = Color.parseColor("#cfd3d8")
+    var targetImageSize: Int = 0
+    var maxWidth: Int = 0
+    var maxHeight: Int = 0
+    var rowCount: Int = 0
+    var columnCount: Int = 0
+    var gap: Int = 6
+
+    constructor()
+
+    constructor(imageUrlList: List<Any?>?, defaultImageResId: Int) {
+        this.imageUrlList = imageUrlList
+        this.defaultImageResId = defaultImageResId
+    }
+
+    fun putBitmap(bitmap: Bitmap, position: Int) {
+        if (bitmapMap == null) {
+            bitmapMap = HashMap()
+        }
+        bitmapMap?.let {
+            synchronized(it) { it.put(position, bitmap) }
+        }
+    }
+
+    fun getBitmap(position: Int): Bitmap? {
+        bitmapMap?.let {
+            synchronized(it) { return it[position] }
+        }
+        return null
+    }
+
+    fun size(): Int {
+        imageUrlList?.let {
+           return min(it.size, MAX_SIZE)
+        }
+        return 0
+    }
+
+    @Throws(CloneNotSupportedException::class)
+    public override fun clone(): GridImageData {
+        val gridImageData = super.clone() as GridImageData
+        imageUrlList?.let {
+            gridImageData.imageUrlList = ArrayList(it.size)
+            (gridImageData.imageUrlList as ArrayList).addAll(it)
+        }
+        bitmapMap?.let {
+            gridImageData.bitmapMap = HashMap()
+            gridImageData.bitmapMap?.putAll(it)
+        }
+        return gridImageData
+    }
+
+    companion object {
+        private const val MAX_SIZE = 9
+    }
+}

+ 328 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/GridImageSynthesizer.kt

@@ -0,0 +1,328 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.text.TextUtils
+import android.widget.ImageView
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader.clear
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader.loadBitmap
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader.loadImage
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage.ThreadUtils.execute
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage.ThreadUtils.runOnUIThread
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.concurrent.ExecutionException
+
+class GridImageSynthesizer(private val mContext: Context, private val mImageView: ImageView) {
+    public var imageId = ""
+    private var gridImageData: GridImageData? = null
+
+    init {
+        init()
+    }
+
+    private fun init() {
+        gridImageData = GridImageData()
+    }
+
+    fun setImageUrls(list: List<Any?>?) {
+        gridImageData?.imageUrlList = list
+    }
+
+    fun setMaxSize(maxWidth: Int, maxHeight: Int) {
+        gridImageData?.maxWidth = maxWidth
+        gridImageData?.maxHeight = maxHeight
+    }
+
+    var defaultImage: Int
+        get() {
+            return gridImageData?.defaultImageResId ?: 0
+        }
+        set(defaultImageResId) {
+            gridImageData?.defaultImageResId = defaultImageResId
+        }
+
+    fun setBgColor(bgColor: Int) {
+        gridImageData?.bgColor = bgColor
+    }
+
+    fun setGap(gap: Int) {
+        gridImageData?.gap = gap
+    }
+
+    private fun calculateGridParam(imagesSize: Int): IntArray {
+        val gridParam = IntArray(2)
+        if (imagesSize < 3) {
+            gridParam[0] = 1
+            gridParam[1] = imagesSize
+        } else if (imagesSize <= 4) {
+            gridParam[0] = 2
+            gridParam[1] = 2
+        } else {
+            gridParam[0] = imagesSize / 3 + if (imagesSize % 3 == 0) 0 else 1
+            gridParam[1] = 3
+        }
+        return gridParam
+    }
+
+    private fun asyncLoadImageList(imageData: GridImageData): Boolean {
+        val loadSuccess = true
+        val imageUrlList = imageData.imageUrlList
+        imageUrlList?.let {
+            for (i in it.indices) {
+                val defaultIcon = BitmapFactory.decodeResource(mContext.resources, R.drawable.tuicallkit_ic_avatar)
+                try {
+                    val bitmap = asyncLoadImage(it[i] ?: "", imageData.targetImageSize)
+                    if (bitmap != null) {
+                        imageData.putBitmap(bitmap, i)
+                    }
+                } catch (e: InterruptedException) {
+                    e.printStackTrace()
+                    imageData.putBitmap(defaultIcon, i)
+                } catch (e: ExecutionException) {
+                    e.printStackTrace()
+                    imageData.putBitmap(defaultIcon, i)
+                }
+            }
+        }
+        return loadSuccess
+    }
+
+    private fun synthesizeImageList(imageData: GridImageData): Bitmap {
+        val mergeBitmap = Bitmap.createBitmap(imageData.maxWidth, imageData.maxHeight, Bitmap.Config.ARGB_8888)
+        val canvas = Canvas(mergeBitmap)
+        drawDrawable(canvas, imageData)
+        canvas.save()
+        canvas.restore()
+        return mergeBitmap
+    }
+
+    private fun drawDrawable(canvas: Canvas, imageData: GridImageData) {
+        canvas.drawColor(imageData.bgColor)
+        val size = imageData.size()
+        val tCenter = (imageData.maxHeight + imageData.gap) / 2
+        val bCenter = (imageData.maxHeight - imageData.gap) / 2
+        val lCenter = (imageData.maxWidth + imageData.gap) / 2
+        val rCenter = (imageData.maxWidth - imageData.gap) / 2
+        val center = (imageData.maxHeight - imageData.targetImageSize) / 2
+        for (i in 0 until size) {
+            val rowNum = i / imageData.columnCount
+            val columnNum = i % imageData.columnCount
+            val left: Int =
+                (imageData.targetImageSize * (if (imageData.columnCount == 1) columnNum + 0.5 else columnNum).toFloat()
+                        + imageData.gap * (columnNum + 1)).toInt()
+            val top: Int = (imageData.targetImageSize * (if (imageData.columnCount == 1) rowNum + 0.5 else rowNum).toFloat()
+                    + imageData.gap * (rowNum + 1)).toInt()
+            val right = left + imageData.targetImageSize
+            val bottom = top + imageData.targetImageSize
+            val bitmap = imageData.getBitmap(i)
+            if (size == 1) {
+                drawBitmapAtPosition(canvas, left, top, right, bottom, bitmap)
+            } else if (size == 2) {
+                drawBitmapAtPosition(canvas, left, center, right, center + imageData.targetImageSize, bitmap)
+            } else if (size == 3) {
+                if (i == 0) {
+                    drawBitmapAtPosition(canvas, center, top, center + imageData.targetImageSize, bottom, bitmap)
+                } else {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * i + imageData.targetImageSize * (i - 1), tCenter,
+                        imageData.gap * i + imageData.targetImageSize * i, tCenter + imageData.targetImageSize,
+                        bitmap
+                    )
+                }
+            } else if (size == 4) {
+                drawBitmapAtPosition(canvas, left, top, right, bottom, bitmap)
+            } else if (size == 5) {
+                if (i == 0) {
+                    drawBitmapAtPosition(
+                        canvas, rCenter - imageData.targetImageSize,
+                        rCenter - imageData.targetImageSize, rCenter, rCenter, bitmap
+                    )
+                } else if (i == 1) {
+                    drawBitmapAtPosition(
+                        canvas, lCenter, rCenter - imageData.targetImageSize,
+                        lCenter + imageData.targetImageSize, rCenter, bitmap
+                    )
+                } else {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i - 1) + imageData.targetImageSize * (i - 2),
+                        tCenter, imageData.gap * (i - 1) + imageData.targetImageSize * (i - 1), tCenter
+                                + imageData.targetImageSize, bitmap
+                    )
+                }
+            } else if (size == 6) {
+                if (i < 3) {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i + 1) + imageData.targetImageSize * i,
+                        bCenter - imageData.targetImageSize,
+                        imageData.gap * (i + 1) + imageData.targetImageSize * (i + 1), bCenter, bitmap
+                    )
+                } else {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i - 2) + imageData.targetImageSize * (i - 3),
+                        tCenter, imageData.gap * (i - 2) + imageData.targetImageSize * (i - 2), tCenter
+                                + imageData.targetImageSize, bitmap
+                    )
+                }
+            } else if (size == 7) {
+                if (i == 0) {
+                    drawBitmapAtPosition(
+                        canvas, center, imageData.gap, center + imageData.targetImageSize,
+                        imageData.gap + imageData.targetImageSize, bitmap
+                    )
+                } else if (i > 0 && i < 4) {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * i + imageData.targetImageSize * (i - 1), center,
+                        imageData.gap * i + imageData.targetImageSize * i, center + imageData.targetImageSize,
+                        bitmap
+                    )
+                } else {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i - 3) + imageData.targetImageSize * (i - 4),
+                        tCenter + imageData.targetImageSize / 2,
+                        imageData.gap * (i - 3) + imageData.targetImageSize * (i - 3),
+                        tCenter + imageData.targetImageSize / 2 + imageData.targetImageSize, bitmap
+                    )
+                }
+            } else if (size == 8) {
+                if (i == 0) {
+                    drawBitmapAtPosition(
+                        canvas, rCenter - imageData.targetImageSize, imageData.gap, rCenter,
+                        imageData.gap + imageData.targetImageSize, bitmap
+                    )
+                } else if (i == 1) {
+                    drawBitmapAtPosition(
+                        canvas, lCenter, imageData.gap, lCenter + imageData.targetImageSize,
+                        imageData.gap + imageData.targetImageSize, bitmap
+                    )
+                } else if (i > 1 && i < 5) {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i - 1) + imageData.targetImageSize * (i - 2),
+                        center, imageData.gap * (i - 1) + imageData.targetImageSize * (i - 1),
+                        center + imageData.targetImageSize, bitmap
+                    )
+                } else {
+                    drawBitmapAtPosition(
+                        canvas, imageData.gap * (i - 4) + imageData.targetImageSize * (i - 5),
+                        tCenter + imageData.targetImageSize / 2,
+                        imageData.gap * (i - 4) + imageData.targetImageSize * (i - 4),
+                        tCenter + imageData.targetImageSize / 2 + imageData.targetImageSize, bitmap
+                    )
+                }
+            } else if (size == 9) {
+                drawBitmapAtPosition(canvas, left, top, right, bottom, bitmap)
+            }
+        }
+    }
+
+    private fun drawBitmapAtPosition(canvas: Canvas, left: Int, top: Int, right: Int, bottom: Int, bitmap: Bitmap?) {
+        if (null != bitmap) {
+            val rect = Rect(left, top, right, bottom)
+            canvas.drawBitmap(bitmap, null, rect, null)
+        }
+    }
+
+    @Throws(ExecutionException::class, InterruptedException::class)
+    private fun asyncLoadImage(imgUrl: Any, targetImageSize: Int): Bitmap? {
+        return loadBitmap(mContext, imgUrl, targetImageSize)
+    }
+
+    fun load(imageId: String?) {
+        gridImageData?.let {
+            if (it.size() == 0) {
+                if (imageId != null && !TextUtils.equals(imageId, this.imageId)) {
+                    return
+                }
+                loadImage(mContext, mImageView, defaultImage)
+                return
+            }
+            if (it.size() == 1) {
+                if (imageId != null && !TextUtils.equals(imageId, this.imageId)) {
+                    return
+                }
+                if (it.imageUrlList!= null) {
+                    loadImage(mContext, mImageView, it.imageUrlList!![0])
+                }
+                return
+            }
+            clearImage()
+            val copyGridImageData = try {
+                it.clone()
+            } catch (e: CloneNotSupportedException) {
+                e.printStackTrace()
+                val urlList: ArrayList<Any?> = ArrayList()
+                if (it.imageUrlList != null) {
+                    urlList.addAll(it.imageUrlList!!)
+                }
+                GridImageData(urlList, it.defaultImageResId)
+            }
+            val gridParam = calculateGridParam(it.size())
+            copyGridImageData.rowCount = gridParam[0]
+            copyGridImageData.columnCount = gridParam[1]
+            copyGridImageData.targetImageSize = (copyGridImageData.maxWidth - (copyGridImageData.columnCount + 1)
+                    * copyGridImageData.gap) / if (copyGridImageData.columnCount == 1) 2 else copyGridImageData.columnCount
+            val finalImageId = imageId
+            execute {
+                val file = File(TUIConfig.getImageBaseDir() + finalImageId)
+                var cacheBitmapExists = false
+                var existsBitmap: Bitmap? = null
+                if (file.exists() && file.isFile) {
+                    val options = BitmapFactory.Options()
+                    existsBitmap = BitmapFactory.decodeFile(file.path, options)
+                    if (options.outWidth > 0 && options.outHeight > 0) {
+                        cacheBitmapExists = true
+                    }
+                }
+                if (!cacheBitmapExists) {
+                    asyncLoadImageList(copyGridImageData)
+                    existsBitmap = synthesizeImageList(copyGridImageData)
+                    storeBitmap(file, existsBitmap)
+                }
+                loadImage(existsBitmap, finalImageId)
+            }
+        }
+    }
+
+    private fun loadImage(bitmap: Bitmap?, targetId: String?) {
+        runOnUIThread {
+            if (TextUtils.equals(imageId, targetId)) {
+                loadImage(mContext, mImageView, bitmap)
+            }
+        }
+    }
+
+    fun clearImage() {
+        clear(mContext, mImageView)
+    }
+
+    private fun storeBitmap(outFile: File, bitmap: Bitmap) {
+        if (!outFile.exists() || outFile.isDirectory) {
+            outFile.parentFile.mkdirs()
+        }
+        var fOut: FileOutputStream? = null
+        try {
+            outFile.deleteOnExit()
+            outFile.createNewFile()
+            fOut = FileOutputStream(outFile)
+            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fOut)
+            fOut.flush()
+        } catch (e: IOException) {
+            outFile.deleteOnExit()
+        } finally {
+            if (null != fOut) {
+                try {
+                    fOut.close()
+                } catch (e: IOException) {
+                    e.printStackTrace()
+                    outFile.deleteOnExit()
+                }
+            }
+        }
+    }
+}

+ 29 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/common/gridimage/ThreadUtils.kt

@@ -0,0 +1,29 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.common.gridimage
+
+import android.os.Handler
+import android.os.Looper
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.SynchronousQueue
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+
+object ThreadUtils {
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private var executors: ExecutorService? = null
+
+    @JvmStatic
+    fun execute(runnable: Runnable) {
+        if (executors == null) {
+            executors = ThreadPoolExecutor(
+                0, Int.MAX_VALUE, 60L, TimeUnit.SECONDS,
+                SynchronousQueue()
+            )
+        }
+        executors?.execute(runnable)
+    }
+
+    @JvmStatic
+    fun runOnUIThread(runnable: Runnable) {
+        mainHandler.post(runnable)
+    }
+}

+ 53 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/CallTimerView.kt

@@ -0,0 +1,53 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component
+
+import android.content.Context
+import androidx.appcompat.widget.AppCompatTextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.util.DateTimeUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.Observer
+
+class CallTimerView(context: Context) : AppCompatTextView(context) {
+
+    private var timeCountObserver = Observer<Int> {
+        this.post {
+            if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Accept) {
+                if (it > 0) {
+                    text = DateTimeUtil.formatSecondsTo00(it)
+                    visibility = VISIBLE
+                }
+            } else {
+                visibility = GONE
+            }
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        setTextColor(context.resources.getColor(R.color.tuicallkit_color_white))
+
+        if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Accept) {
+            text = DateTimeUtil.formatSecondsTo00(TUICallState.instance.timeCount.get())
+            visibility = VISIBLE
+        } else {
+            visibility = GONE
+        }
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.timeCount.observe(timeCountObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.timeCount.removeObserver(timeCountObserver)
+    }
+}

+ 103 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/CallWaitingHintView.kt

@@ -0,0 +1,103 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component
+
+import android.content.Context
+import android.view.Gravity
+import android.view.View
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUIConfig
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.Observer
+
+class CallWaitingHintView(context: Context) : androidx.appcompat.widget.AppCompatTextView(context) {
+
+    private var isFirstShowAccept = true
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        updateStatusText()
+    }
+
+    private var networkQualityObserver = Observer<Constants.NetworkQualityHint> {
+        when (it) {
+            Constants.NetworkQualityHint.Local ->
+                text = TUIConfig.getAppContext().getString(R.string.tuicallkit_self_network_low_quality)
+
+            Constants.NetworkQualityHint.Remote ->
+                text = TUIConfig.getAppContext().getString(R.string.tuicallkit_other_party_network_low_quality)
+
+            else -> updateStatusText()
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        setTextColor(context.resources.getColor(R.color.tuicallkit_color_white))
+        gravity = Gravity.CENTER
+
+        text = if (TUICallDefine.Scene.GROUP_CALL == TUICallState.instance.scene.get()) {
+            if (TUICallDefine.Role.Caller == TUICallState.instance.selfUser.get().callRole.get()) {
+                context.getString(R.string.tuicallkit_wait_response)
+            } else {
+                context.getString(R.string.tuicallkit_wait_accept_group)
+            }
+        } else {
+            updateStatusText()
+        }
+    }
+
+    private fun updateSingleCallWaitingText(): String {
+        return if (TUICallDefine.Role.Caller == TUICallState.instance.selfUser.get().callRole.get()) {
+            context.getString(R.string.tuicallkit_waiting_accept)
+        } else {
+            if (TUICallDefine.MediaType.Video == TUICallState.instance.mediaType.get()) {
+                context.getString(R.string.tuicallkit_invite_video_call)
+            } else {
+                context.getString(R.string.tuicallkit_invite_audio_call)
+            }
+        }
+    }
+
+    private fun updateStatusText(): String {
+        if (TUICallDefine.Scene.GROUP_CALL == TUICallState.instance.scene.get()
+            && TUICallDefine.Status.Accept == TUICallState.instance.selfUser.get().callStatus.get()
+        ) {
+            visibility = View.GONE
+            return ""
+        }
+        if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Waiting) {
+            text = updateSingleCallWaitingText()
+        } else if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Accept) {
+            if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Caller && isFirstShowAccept) {
+                text = context.getString(R.string.tuicallkit_accept_single)
+                postDelayed({
+                    isFirstShowAccept = false
+                }, 2000)
+            } else {
+                text = ""
+            }
+        } else {
+            text = ""
+        }
+
+        return text.toString()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICallState.instance.networkQualityReminder.observe(networkQualityObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICallState.instance.networkQualityReminder.removeObserver(networkQualityObserver)
+    }
+}

+ 99 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/InviteUserButton.kt

@@ -0,0 +1,99 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.ViewGroup
+import android.widget.ImageView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.ServiceInitializer
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.util.ToastUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.trtc.tuikit.common.livedata.Observer
+
+@SuppressLint("AppCompatCustomView")
+class InviteUserButton(context: Context) : ImageView(context) {
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (TUICallDefine.Status.Accept == it) {
+            this.visibility = VISIBLE
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        setBackgroundResource(R.drawable.tuicallkit_ic_add_user_black)
+        val lp = ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.MATCH_PARENT
+        )
+        layoutParams = lp
+
+        val isCaller = TUICallDefine.Role.Caller == TUICallState.instance.selfUser.get().callRole.get()
+        val isAccept = TUICallDefine.Status.Accept == TUICallState.instance.selfUser.get().callStatus.get()
+        visibility = when {
+            isCaller || isAccept -> VISIBLE
+            else -> GONE
+        }
+        setOnClickListener {
+            inviteUser()
+        }
+    }
+
+    private fun inviteUser() {
+        val groupId = TUICallState.instance.groupId.get()
+        if (TextUtils.isEmpty(groupId)) {
+            ToastUtil.toastShortMessage(
+                ServiceInitializer.getAppContext().getString(R.string.tuicallkit_group_id_is_empty)
+            )
+            return
+        }
+        val status: TUICallDefine.Status = TUICallState.instance.selfUser.get().callStatus.get()
+        if (TUICallDefine.Role.Called == TUICallState.instance.selfUser.get().callRole?.get()
+            && TUICallDefine.Status.Accept != status
+        ) {
+            Logger.info(TAG, "This feature can only be used after the callee accepted the call.")
+            return
+        }
+        val list = ArrayList<String?>()
+        for (model in TUICallState.instance.remoteUserList.get()) {
+            if (model != null && !TextUtils.isEmpty(model.id) && !list.contains(model.id)) {
+                list.add(model.id)
+            }
+        }
+        if (!list.contains(TUILogin.getLoginUser())) {
+            list.add(TUILogin.getLoginUser())
+        }
+        Logger.info(TAG, "inviteUserButtonClicked, groupId: $groupId ,list: $list")
+        val bundle = Bundle()
+        bundle.putString(Constants.GROUP_ID, groupId)
+        bundle.putStringArrayList(Constants.SELECT_MEMBER_LIST, list)
+        TUICore.startActivity("SelectGroupMemberActivity", bundle)
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+    }
+
+    companion object {
+        private const val TAG = "InviteUserButton"
+    }
+}

+ 227 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatWindowService.kt

@@ -0,0 +1,227 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview
+
+import android.animation.ValueAnimator
+import android.app.Service
+import android.content.Intent
+import android.content.res.Configuration
+import android.graphics.PixelFormat
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.WindowManager
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.ServiceInitializer
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+
+class FloatWindowService : Service() {
+    private var windowManager: WindowManager? = null
+    private var windowLayoutParams: WindowManager.LayoutParams? = null
+    private var screenWidth = 0
+    private var callViewWidth = 0
+    private var touchStartX = 0
+    private var touchStartY = 0
+    private var touchCurrentX = 0
+    private var touchCurrentY = 0
+    private var startX = 0
+    private var startY = 0
+    private var stopX = 0
+    private var stopY = 0
+    private var isMove = false
+
+    companion object {
+        private const val TAG = "FloatWindowService"
+        private var callView: BaseCallView? = null
+
+        fun startFloatService(view: BaseCallView) {
+            Logger.info(TAG, "startFloatService, view: $callView")
+            this.callView = view
+            this.callView?.setOnClickListener {
+                stopService()
+                if (TUICallState.instance.selfUser.get().callStatus.get() != TUICallDefine.Status.None) {
+                    val intent = Intent(ServiceInitializer.getAppContext(), CallKitActivity::class.java)
+                    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+                    ServiceInitializer.getAppContext().startActivity(intent)
+                }
+            }
+            val serviceIntent = Intent(ServiceInitializer.getAppContext(), FloatWindowService::class.java)
+            ServiceInitializer.getAppContext().startService(serviceIntent)
+        }
+
+        fun stopService() {
+            Logger.info(TAG, "stopService")
+            if (callView != null) {
+                callView?.clear()
+            }
+            val serviceIntent = Intent(ServiceInitializer.getAppContext(), FloatWindowService::class.java)
+            ServiceInitializer.getAppContext().stopService(serviceIntent)
+        }
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        initWindow()
+    }
+
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        if (callView == null || windowLayoutParams == null || windowManager == null) {
+            return
+        }
+        windowLayoutParams?.x = 0
+        val screenWidth = ScreenUtil.getScreenWidth(applicationContext)
+        val screenHeight = ScreenUtil.getScreenHeight(applicationContext)
+        windowLayoutParams?.y = if (screenWidth > screenHeight) {
+            (screenHeight - callView!!.height) / 2
+        } else {
+            ScreenUtil.dip2px(100f)
+        }
+        callView?.let { windowManager?.updateViewLayout(it, windowLayoutParams) }
+    }
+
+    override fun onBind(intent: Intent): IBinder {
+        return FloatBinder()
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        return START_NOT_STICKY
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        if (null != callView && callView!!.isAttachedToWindow) {
+            windowManager?.removeView(callView)
+        }
+        callView = null
+    }
+
+    private fun initWindow() {
+        windowManager = applicationContext.getSystemService(WINDOW_SERVICE) as WindowManager
+        windowLayoutParams = viewParams
+        screenWidth = ScreenUtil.getScreenWidth(applicationContext)
+        if (null != callView) {
+            Logger.info(TAG, "startFloatService, addView: $callView")
+            windowManager!!.addView(callView, windowLayoutParams)
+            TUICore.notifyEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FLOAT_VIEW, HashMap())
+            callView!!.setOnTouchListener(FloatingListener())
+        }
+    }
+
+    private val viewParams: WindowManager.LayoutParams
+        private get() {
+            windowLayoutParams = WindowManager.LayoutParams()
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                windowLayoutParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+            } else {
+                windowLayoutParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
+            }
+            windowLayoutParams!!.flags = (WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+                    or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+                    or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
+            windowLayoutParams!!.gravity = Gravity.END or Gravity.TOP
+            windowLayoutParams!!.x = 10
+            windowLayoutParams!!.y = ScreenUtil.dip2px(100f)
+            windowLayoutParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
+            windowLayoutParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
+            windowLayoutParams!!.format = PixelFormat.TRANSPARENT
+            return windowLayoutParams as WindowManager.LayoutParams
+        }
+
+    inner class FloatBinder : Binder() {
+        val service: FloatWindowService
+            get() = this@FloatWindowService
+    }
+
+    inner class FloatingListener : View.OnTouchListener {
+        override fun onTouch(v: View, event: MotionEvent): Boolean {
+            val action = event.action
+            when (action) {
+                MotionEvent.ACTION_DOWN -> {
+                    isMove = false
+                    touchStartX = event.rawX.toInt()
+                    touchStartY = event.rawY.toInt()
+                    startX = event.rawX.toInt()
+                    startY = event.rawY.toInt()
+                }
+
+                MotionEvent.ACTION_MOVE -> {
+                    touchCurrentX = event.rawX.toInt()
+                    touchCurrentY = event.rawY.toInt()
+                    if (windowLayoutParams != null && null != callView) {
+                        windowLayoutParams!!.x += touchStartX - touchCurrentX
+                        windowLayoutParams!!.y += touchCurrentY - touchStartY
+                        windowManager!!.updateViewLayout(callView, windowLayoutParams)
+                    }
+                    touchStartX = touchCurrentX
+                    touchStartY = touchCurrentY
+                }
+
+                MotionEvent.ACTION_UP -> {
+                    stopX = event.rawX.toInt()
+                    stopY = event.rawY.toInt()
+                    if (Math.abs(startX - stopX) >= 5 || Math.abs(startY - stopY) >= 5) {
+                        isMove = true
+                        if (null != callView) {
+                            callViewWidth = callView!!.width
+                            if (touchCurrentX < screenWidth / 2) {
+                                startScroll(screenWidth - callViewWidth, stopX, false)
+                            } else {
+                                startScroll(0, stopX, true)
+                            }
+                        }
+                    }
+                }
+
+                else -> {}
+            }
+            return isMove
+        }
+    }
+
+    private fun startScroll(start: Int, end: Int, isLeft: Boolean) {
+        val valueAnimator = ValueAnimator.ofFloat(start.toFloat(), end.toFloat()).setDuration(300)
+        valueAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { animation ->
+            if (windowLayoutParams == null || callView == null) {
+                return@AnimatorUpdateListener
+            }
+            callViewWidth = callView!!.width
+            if (isLeft) {
+                windowLayoutParams!!.x = (start * (1 - animation.animatedFraction)).toInt()
+            } else {
+                val end = (screenWidth - start - callViewWidth) * animation.animatedFraction
+                windowLayoutParams!!.x = (start + end).toInt()
+            }
+            if (windowLayoutParams!!.x > screenWidth - callViewWidth) {
+                windowLayoutParams!!.x = screenWidth - callViewWidth
+            }
+            calculateHeight()
+            windowManager!!.updateViewLayout(callView, windowLayoutParams)
+        })
+        valueAnimator.start()
+    }
+
+    private fun calculateHeight() {
+        if (windowLayoutParams == null) {
+            return
+        }
+        val height = callView!!.height
+        val screenHeight = ScreenUtil.getScreenHeight(applicationContext)
+        val resourceId = ServiceInitializer.getAppContext().resources
+            .getIdentifier("status_bar_height", "dimen", "android")
+        val statusBarHeight = ServiceInitializer.getAppContext().resources.getDimensionPixelSize(resourceId)
+        if (windowLayoutParams!!.y < 0) {
+            windowLayoutParams!!.y = 0
+        } else if (windowLayoutParams!!.y > screenHeight - height - statusBarHeight) {
+            windowLayoutParams!!.y = screenHeight - height - statusBarHeight
+        }
+    }
+
+}

+ 73 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowButton.kt

@@ -0,0 +1,73 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.ViewGroup
+import android.widget.ImageView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.ServiceInitializer
+import com.tencent.qcloud.tuicore.permission.PermissionRequester
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity
+import com.trtc.tuikit.common.livedata.Observer
+
+@SuppressLint("AppCompatCustomView")
+class FloatingWindowButton(context: Context) : ImageView(context) {
+
+    private val callStatusObserver = Observer<TUICallDefine.Status> {
+        if (TUICallState.instance.enableFloatWindow) {
+            this.visibility = VISIBLE
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        setBackgroundResource(R.drawable.tuicallkit_ic_move_back_white)
+        val lp = ViewGroup.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.MATCH_PARENT
+        )
+        layoutParams = lp
+
+        setOnClickListener {
+            if (PermissionRequester.newInstance(PermissionRequester.FLOAT_PERMISSION).has()) {
+                showFloatView()
+            } else {
+                PermissionRequest.requestFloatPermission(ServiceInitializer.getAppContext())
+            }
+        }
+
+        visibility = if (TUICallState.instance.enableFloatWindow) {
+            VISIBLE
+        } else {
+            GONE
+        }
+    }
+
+    private fun showFloatView() {
+        if (TUICallState.instance.scene.get() == TUICallDefine.Scene.GROUP_CALL) {
+            FloatWindowService.startFloatService(FloatingWindowGroupView(context.applicationContext))
+        } else {
+            FloatWindowService.startFloatService(FloatingWindowView(context.applicationContext))
+        }
+        CallKitActivity.finishActivity()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+    }
+}

+ 186 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowGroupView.kt

@@ -0,0 +1,186 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.RelativeLayout
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.util.DateTimeUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.floatview.FloatingWindowViewModel
+import com.trtc.tuikit.common.livedata.Observer
+
+class FloatingWindowGroupView(context: Context) : BaseCallView(context) {
+    private var layoutVideoView: RelativeLayout? = null
+    private var imageAudioIcon: ImageView? = null
+    private var textCallStatus: TextView? = null
+    private var imageAvatar: ImageView? = null
+    private var textName: TextView? = null
+    private var videoView: VideoView? = null
+    private var layoutFloatMark: LinearLayout? = null
+    private var imageFloatVideoMark: ImageView? = null
+    private var imageFloatAudioMark: ImageView? = null
+
+    private var currentShowVideUserId: String? = null
+    private var viewModel: FloatingWindowViewModel = FloatingWindowViewModel()
+
+    private val observer: Observer<Any> = Observer {
+        updateView(it)
+    }
+
+    private val timeCountObserver = Observer<Int> {
+        if (viewModel.selfUser.callStatus.get() == TUICallDefine.Status.Accept) {
+            textCallStatus?.post {
+                textCallStatus?.text = DateTimeUtil.formatSecondsTo00(viewModel.timeCount.get())
+            }
+        }
+    }
+
+    init {
+        initView(context)
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+        currentShowVideUserId = null
+    }
+
+    private fun addObserver() {
+        viewModel.timeCount.observe(timeCountObserver)
+
+        viewModel.selfUser.callStatus.observe(observer)
+        viewModel.selfUser.videoAvailable.observe(observer)
+        viewModel.selfUser.playoutVolume.observe(observer)
+        for (user in viewModel.remoteUserList) {
+            user.videoAvailable.observe(observer)
+            user.playoutVolume.observe(observer)
+        }
+    }
+
+    private fun removeObserver() {
+        viewModel.timeCount.removeObserver(timeCountObserver)
+
+        viewModel.selfUser.callStatus.removeObserver(observer)
+        viewModel.selfUser.videoAvailable.removeObserver(observer)
+        viewModel.selfUser.playoutVolume.removeObserver(observer)
+        for (user in viewModel.remoteUserList) {
+            user.videoAvailable.removeObserver(observer)
+            user.playoutVolume.removeObserver(observer)
+        }
+    }
+
+    private fun initView(context: Context) {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_float_call_group_view, this)
+        layoutVideoView = findViewById(R.id.rl_video_view)
+        imageAvatar = findViewById(R.id.iv_avatar)
+        textName = findViewById(R.id.tv_float_name)
+        textCallStatus = findViewById(R.id.tv_call_status)
+        imageAudioIcon = findViewById(R.id.iv_audio_view_icon)
+        layoutFloatMark = findViewById(R.id.ll_float_mark)
+        imageFloatVideoMark = findViewById(R.id.iv_float_video_mark)
+        imageFloatAudioMark = findViewById(R.id.iv_float_audio_mark)
+
+        updateView(null)
+    }
+
+    private fun updateView(it: Any?) {
+        if (viewModel.selfUser.callStatus.get() == TUICallDefine.Status.None) {
+            VideoViewFactory.instance.clear()
+            clear()
+            viewModel.stopFloatService()
+            return
+        }
+
+        updateFloatMarkOfSelfUser()
+
+        if (it != null && it is Int && it < Constants.MIN_AUDIO_VOLUME) {
+            return
+        }
+
+        val list = ArrayList<User>()
+        list.addAll(viewModel.remoteUserList)
+        list.add(viewModel.selfUser)
+
+        for (user in list) {
+            if (user.playoutVolume.get() > Constants.MIN_AUDIO_VOLUME) {
+                imageAudioIcon?.visibility = GONE
+                textCallStatus?.visibility = GONE
+                textName?.visibility = VISIBLE
+                textName?.text = user.nickname.get()
+
+                if (user.videoAvailable.get() == true) {
+                    if (user.id == currentShowVideUserId) {
+                        return
+                    }
+                    currentShowVideUserId = user.id
+
+                    imageAvatar?.visibility = GONE
+                    layoutVideoView?.visibility = VISIBLE
+                    resetLayoutVideoView(user)
+                    return
+                }
+                currentShowVideUserId = null
+                imageAvatar?.visibility = VISIBLE
+                layoutVideoView?.visibility = GONE
+                ImageLoader.loadImage(context, imageAvatar, user.avatar.get())
+                return
+            }
+        }
+        currentShowVideUserId = null
+        textCallStatus?.text = if (viewModel.selfUser.callStatus.get() == TUICallDefine.Status.Waiting) {
+            context.getString(R.string.tuicallkit_wait_response)
+        } else {
+            DateTimeUtil.formatSecondsTo00(viewModel.timeCount.get())
+        }
+
+        textCallStatus?.visibility = VISIBLE
+        imageAudioIcon?.visibility = VISIBLE
+        imageAvatar?.visibility = GONE
+        textName?.visibility = GONE
+        layoutVideoView?.visibility = GONE
+    }
+
+    private fun resetLayoutVideoView(user: User) {
+        videoView = VideoViewFactory.instance.createVideoView(user, context)
+        videoView?.setVideoIconVisibility(false)
+
+        if (videoView != null && videoView?.parent != null) {
+            (videoView?.parent as ViewGroup).removeView(videoView)
+            layoutVideoView?.removeAllViews()
+        }
+        layoutVideoView?.addView(videoView)
+        if (user.id != viewModel.selfUser.id) {
+            EngineManager.instance.startRemoteView(user.id, videoView?.getVideoView(), null)
+        }
+    }
+
+    private fun updateFloatMarkOfSelfUser() {
+        if (viewModel.selfUser.videoAvailable.get() == true) {
+            imageFloatVideoMark?.setImageResource(R.drawable.tuicallkit_ic_float_video_on)
+        } else {
+            imageFloatVideoMark?.setImageResource(R.drawable.tuicallkit_ic_float_video_off)
+        }
+
+        if (viewModel.selfUser.audioAvailable.get() == true) {
+            imageFloatAudioMark?.setImageResource(R.drawable.tuicallkit_ic_float_audio_on)
+        } else {
+            imageFloatAudioMark?.setImageResource(R.drawable.tuicallkit_ic_float_audio_off)
+        }
+    }
+
+    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
+        return true
+    }
+}

+ 141 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/floatview/FloatingWindowView.kt

@@ -0,0 +1,141 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.RelativeLayout
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.util.DateTimeUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.floatview.FloatingWindowViewModel
+import com.trtc.tuikit.common.livedata.Observer
+
+class FloatingWindowView(context: Context) : BaseCallView(context) {
+
+    private var layoutAvatar: FrameLayout? = null
+    private var layoutVideoView: RelativeLayout? = null
+    private var imageAudioIcon: ImageView? = null
+    private var textCallStatus: TextView? = null
+    private var imageAvatar: ImageView? = null
+    private var videoView: VideoView? = null
+    private var viewModel: FloatingWindowViewModel = FloatingWindowViewModel()
+    private val observer: Observer<Any> = Observer {
+        updateView(it)
+    }
+
+    init {
+        initView(context)
+
+        addObserver()
+    }
+
+    override fun clear() {
+        videoView?.clear()
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        viewModel?.mediaType?.observe(observer)
+        viewModel?.timeCount?.observe(observer)
+        viewModel?.selfUser?.callStatus?.observe(observer)
+        viewModel.remoteUser?.videoAvailable?.observe(observer)
+    }
+
+    private fun removeObserver() {
+        viewModel?.mediaType?.removeObserver(observer)
+        viewModel?.timeCount?.removeObserver(observer)
+        viewModel?.selfUser?.callStatus?.removeObserver(observer)
+        viewModel.remoteUser?.videoAvailable?.removeObserver(observer)
+    }
+
+    private fun initView(context: Context) {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_float_call_view, this)
+        layoutVideoView = findViewById(R.id.rl_video_view)
+        imageAvatar = findViewById(R.id.iv_avatar)
+        textCallStatus = findViewById(R.id.tv_call_status)
+        imageAudioIcon = findViewById(R.id.iv_audio_view_icon)
+        layoutAvatar = findViewById(R.id.fl_avatar)
+
+        updateView(null)
+    }
+
+    private fun updateView(it: Any?) {
+        if (it != null && it is Int && it > 0) {
+            textCallStatus?.post {
+                textCallStatus?.text = DateTimeUtil.formatSecondsTo00(it)
+            }
+            return
+        }
+        if (viewModel.mediaType.get() == TUICallDefine.MediaType.Audio
+            || viewModel.scene.get() == TUICallDefine.Scene.GROUP_CALL
+        ) {
+            imageAudioIcon?.visibility = VISIBLE
+            textCallStatus?.visibility = VISIBLE
+            layoutVideoView?.visibility = GONE
+            layoutAvatar?.visibility = GONE
+            if (viewModel.selfUser?.callStatus?.get() == TUICallDefine.Status.Waiting) {
+                textCallStatus?.text = context.getString(R.string.tuicallkit_wait_response)
+            } else if (viewModel.selfUser?.callStatus?.get() == TUICallDefine.Status.Accept) {
+                textCallStatus?.text = DateTimeUtil.formatSecondsTo00(viewModel.timeCount.get())
+            } else {
+                VideoViewFactory.instance.clear()
+                clear()
+                viewModel.stopFloatService()
+            }
+        } else if (viewModel.mediaType.get() == TUICallDefine.MediaType.Video) {
+            imageAudioIcon?.visibility = GONE
+            if (viewModel.selfUser?.callStatus?.get() == TUICallDefine.Status.Waiting) {
+                layoutVideoView?.visibility = VISIBLE
+                layoutAvatar?.visibility = GONE
+                textCallStatus?.visibility = VISIBLE
+                textCallStatus?.text = context.getString(R.string.tuicallkit_wait_response)
+                textCallStatus?.setTextColor(context.resources.getColor(R.color.tuicallkit_color_white))
+                videoView = VideoViewFactory.instance.createVideoView(viewModel.selfUser, context)
+                if (videoView != null && videoView?.parent != null) {
+                    (videoView?.parent as ViewGroup).removeView(videoView)
+                    layoutVideoView?.removeAllViews()
+                }
+                layoutVideoView?.addView(videoView)
+            } else if (viewModel.selfUser?.callStatus?.get() == TUICallDefine.Status.Accept) {
+                if (viewModel.remoteUser?.videoAvailable?.get() == true) {
+                    layoutVideoView?.visibility = VISIBLE
+                    layoutAvatar?.visibility = GONE
+                    textCallStatus?.visibility = GONE
+                    videoView = VideoViewFactory.instance.createVideoView(viewModel.remoteUser, context)
+                    if (videoView != null && videoView?.parent != null) {
+                        (videoView?.parent as ViewGroup).removeView(videoView)
+                        layoutVideoView?.removeAllViews()
+                    }
+                    layoutVideoView?.addView(videoView)
+                    EngineManager.instance.startRemoteView(
+                        viewModel.remoteUser?.id,
+                        videoView?.getVideoView(),
+                        null
+                    )
+                } else {
+                    layoutVideoView?.visibility = GONE
+                    layoutAvatar?.visibility = VISIBLE
+
+                    ImageLoader.loadImage(
+                        context,
+                        imageAvatar,
+                        viewModel.remoteUser?.avatar?.get(),
+                        R.drawable.tuicallkit_ic_avatar
+                    )
+                }
+            } else {
+                VideoViewFactory.instance.clear()
+                clear()
+                viewModel.stopFloatService()
+            }
+        }
+    }
+}

+ 38 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/AudioAndVideoCalleeWaitingView.kt

@@ -0,0 +1,38 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.function
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+
+class AudioAndVideoCalleeWaitingView(context: Context) : BaseCallView(context) {
+    private var layoutReject: LinearLayout? = null
+    private var layoutDialing: LinearLayout? = null
+    private var textReject: TextView? = null
+    private var textDialing: TextView? = null
+
+    init {
+        initView()
+    }
+
+    override fun clear() {
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_function_view_invited_waiting, this)
+        layoutReject = findViewById(R.id.ll_decline)
+        layoutDialing = findViewById(R.id.ll_answer)
+        textReject = findViewById(R.id.tv_reject)
+        textDialing = findViewById(R.id.tv_dialing)
+
+        initViewListener()
+    }
+
+    private fun initViewListener() {
+        layoutReject!!.setOnClickListener { EngineManager.instance.reject(null) }
+        layoutDialing!!.setOnClickListener { EngineManager.instance.accept(null) }
+    }
+}

+ 109 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/AudioCallerWaitingAndAcceptedView.kt

@@ -0,0 +1,109 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.function
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class AudioCallerWaitingAndAcceptedView(context: Context) : BaseCallView(context) {
+    private var layoutMute: LinearLayout? = null
+    private var layoutHangup: LinearLayout? = null
+    private var layoutHandsFree: LinearLayout? = null
+    private var imageMute: ImageView? = null
+    private var imageHandsFree: ImageView? = null
+    private var textMic: TextView? = null
+    private var textAudioDevice: TextView? = null
+
+    private var isMicMuteObserver = Observer<Boolean> {
+        imageMute?.isActivated = it
+        textMic?.text = getMicText()
+    }
+
+    private var audioPlayoutDeviceObserver = Observer<TUICommonDefine.AudioPlaybackDevice> {
+        if (it == TUICommonDefine.AudioPlaybackDevice.Speakerphone) {
+            imageHandsFree?.isActivated = true
+        } else {
+            imageHandsFree?.isActivated = false
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.isMicrophoneMute.observe(isMicMuteObserver)
+        TUICallState.instance.audioPlayoutDevice?.observe(audioPlayoutDeviceObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.isMicrophoneMute.removeObserver(isMicMuteObserver)
+        TUICallState.instance.audioPlayoutDevice?.removeObserver(audioPlayoutDeviceObserver)
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_function_view_audio, this)
+        layoutMute = findViewById(R.id.ll_mute)
+        imageMute = findViewById(R.id.img_mute)
+        layoutHangup = findViewById(R.id.ll_hangup)
+        layoutHandsFree = findViewById(R.id.ll_handsfree)
+        imageHandsFree = findViewById(R.id.img_handsfree)
+        textMic = findViewById(R.id.tv_mic)
+        textAudioDevice = findViewById(R.id.tv_audio_device)
+
+        imageMute?.isActivated = TUICallState.instance.isMicrophoneMute.get() == true
+        imageHandsFree?.isActivated =
+            TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone
+        textMic?.text = getMicText()
+        textAudioDevice?.text = getAudioDeviceText()
+
+        initViewListener()
+    }
+
+    private fun initViewListener() {
+        layoutMute?.setOnClickListener {
+            if (TUICallState.instance.isMicrophoneMute.get() == true) {
+                EngineManager.instance.openMicrophone(null)
+            } else {
+                EngineManager.instance.closeMicrophone()
+            }
+        }
+        layoutHangup?.setOnClickListener { EngineManager.instance.hangup(null) }
+        layoutHandsFree?.setOnClickListener {
+            if (TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone) {
+                EngineManager.instance.selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Earpiece)
+            } else {
+                EngineManager.instance.selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Speakerphone)
+            }
+            textAudioDevice?.text = getAudioDeviceText()
+        }
+    }
+
+    private fun getMicText(): String {
+        return if (TUICallState.instance.isMicrophoneMute.get() == true) {
+            context.getString(R.string.tuicallkit_toast_enable_mute)
+        } else {
+            context.getString(R.string.tuicallkit_toast_disable_mute)
+        }
+    }
+
+    private fun getAudioDeviceText(): String {
+        return if (TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone) {
+            context.getString(R.string.tuicallkit_toast_speaker)
+        } else {
+            context.getString(R.string.tuicallkit_toast_use_earpiece)
+        }
+    }
+}

+ 231 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/VideoCallerAndCalleeAcceptedView.kt

@@ -0,0 +1,231 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.function
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.motion.widget.MotionLayout
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class VideoCallerAndCalleeAcceptedView(context: Context) : BaseCallView(context) {
+    private var rootLayout: MotionLayout? = null
+    private var imageOpenCamera: ImageView? = null
+    private var imageMute: ImageView? = null
+    private var imageAudioDevice: ImageView? = null
+    private var imageHangup: ImageView? = null
+    private var imageSwitchCamera: ImageView? = null
+    private var imageExpandView: ImageView? = null
+    private var imageBlurBackground: ImageView? = null
+    private var textMute: TextView? = null
+    private var textAudioDevice: TextView? = null
+    private var textCamera: TextView? = null
+
+    private var isCameraOpenObserver = Observer<Boolean> {
+        imageOpenCamera?.isActivated = it
+        textCamera?.text = if (it) {
+            context.getString(R.string.tuicallkit_toast_enable_camera)
+        } else {
+            context.getString(R.string.tuicallkit_toast_disable_camera)
+        }
+
+        if (it && TUICallState.instance.scene.get() == TUICallDefine.Scene.SINGLE_CALL) {
+            refreshButton(R.id.iv_function_switch_camera, VISIBLE)
+            refreshButton(
+                R.id.img_blur_background, if (TUICallState.instance.showVirtualBackgroundButton) VISIBLE else GONE
+            )
+        } else {
+            refreshButton(R.id.iv_function_switch_camera, GONE)
+            refreshButton(R.id.img_blur_background, GONE)
+        }
+    }
+
+    private fun refreshButton(resId: Int, enable: Int) {
+        rootLayout?.getConstraintSet(R.id.start)?.getConstraint(resId)?.propertySet?.visibility = enable
+        rootLayout?.getConstraintSet(R.id.end)?.getConstraint(resId)?.propertySet?.visibility = enable
+    }
+
+    private var isMicMuteObserver = Observer<Boolean> {
+        imageMute?.isActivated = it
+    }
+
+    private var isSpeakerObserver = Observer<TUICommonDefine.AudioPlaybackDevice> {
+        imageAudioDevice?.isActivated = it == TUICommonDefine.AudioPlaybackDevice.Speakerphone
+    }
+
+    private val isBottomViewExpandedObserver = Observer<Boolean> {
+        updateView(it)
+        enableSwipeFunctionView(true)
+    }
+
+    init {
+        initView()
+
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.isCameraOpen.observe(isCameraOpenObserver)
+        TUICallState.instance.isMicrophoneMute.observe(isMicMuteObserver)
+        TUICallState.instance.audioPlayoutDevice.observe(isSpeakerObserver)
+        TUICallState.instance.isBottomViewExpand.observe(isBottomViewExpandedObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.isCameraOpen.removeObserver(isCameraOpenObserver)
+        TUICallState.instance.isMicrophoneMute.removeObserver(isMicMuteObserver)
+        TUICallState.instance.audioPlayoutDevice.removeObserver(isSpeakerObserver)
+        TUICallState.instance.isBottomViewExpand.removeObserver(isBottomViewExpandedObserver)
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_function_view_video, this)
+        rootLayout = findViewById(R.id.cl_view_video)
+        imageMute = findViewById(R.id.iv_mute)
+        textMute = findViewById(R.id.tv_mic)
+        imageAudioDevice = findViewById(R.id.iv_speaker)
+        textAudioDevice = findViewById(R.id.tv_speaker)
+        imageOpenCamera = findViewById(R.id.iv_camera)
+        imageHangup = findViewById(R.id.iv_hang_up)
+        textCamera = findViewById(R.id.tv_video_camera)
+        imageSwitchCamera = findViewById(R.id.iv_function_switch_camera)
+        imageBlurBackground = findViewById(R.id.img_blur_background)
+        imageExpandView = findViewById(R.id.iv_expanded)
+        imageExpandView?.visibility = INVISIBLE
+
+        imageOpenCamera?.isActivated = TUICallState.instance.isCameraOpen.get() == true
+        imageMute?.isActivated = TUICallState.instance.isMicrophoneMute.get() == true
+        imageAudioDevice?.isActivated =
+            TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone
+
+        textCamera?.text = if (TUICallState.instance.isCameraOpen.get()) {
+            context.getString(R.string.tuicallkit_toast_enable_camera)
+        } else {
+            context.getString(R.string.tuicallkit_toast_disable_camera)
+        }
+
+        if (TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone) {
+            textAudioDevice?.text = context.getString(R.string.tuicallkit_toast_speaker)
+        } else {
+            textAudioDevice?.text = context.getString(R.string.tuicallkit_toast_use_earpiece)
+        }
+
+        if (TUICallState.instance.scene.get() == TUICallDefine.Scene.SINGLE_CALL
+            && TUICallState.instance.isCameraOpen.get()
+        ) {
+            imageSwitchCamera?.visibility = VISIBLE
+            imageBlurBackground?.visibility = if (TUICallState.instance.showVirtualBackgroundButton) VISIBLE else GONE
+        } else {
+            imageSwitchCamera?.visibility = GONE
+            imageBlurBackground?.visibility = GONE
+        }
+
+        if (!TUICallState.instance.isBottomViewExpand.get() && TUICallState.instance.showLargeViewUserId.get() != null) {
+            TUICallState.instance.isBottomViewExpand.set(!TUICallState.instance.isBottomViewExpand.get())
+        }
+        initViewListener()
+        enableSwipeFunctionView(false)
+    }
+
+    private fun enableSwipeFunctionView(enable: Boolean) {
+        if (TUICallState.instance.scene.get() == TUICallDefine.Scene.SINGLE_CALL) {
+            rootLayout?.enableTransition(R.id.video_function_view_transition, false)
+            return
+        }
+        rootLayout?.enableTransition(R.id.video_function_view_transition, enable)
+    }
+
+    private fun initViewListener() {
+        imageMute?.setOnClickListener {
+            val resId = if (TUICallState.instance.isMicrophoneMute.get() == true) {
+                EngineManager.instance.openMicrophone(null)
+                R.string.tuicallkit_toast_disable_mute
+            } else {
+                EngineManager.instance.closeMicrophone()
+                R.string.tuicallkit_toast_enable_mute
+            }
+            textMute?.text = context.getString(resId)
+        }
+        imageAudioDevice?.setOnClickListener {
+            var resId: Int
+            if (TUICallState.instance.audioPlayoutDevice.get() == TUICommonDefine.AudioPlaybackDevice.Speakerphone) {
+                EngineManager.instance.selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Earpiece)
+                resId = R.string.tuicallkit_toast_use_earpiece
+            } else {
+                EngineManager.instance.selectAudioPlaybackDevice(TUICommonDefine.AudioPlaybackDevice.Speakerphone)
+                resId = R.string.tuicallkit_toast_speaker
+            }
+            textAudioDevice?.text = context.getString(resId)
+        }
+        imageOpenCamera?.setOnClickListener {
+            if (TUICallState.instance.isCameraOpen.get() == true) {
+                EngineManager.instance.closeCamera()
+            } else {
+                var camera: TUICommonDefine.Camera = TUICallState.instance.isFrontCamera.get()
+                val videoView = VideoViewFactory.instance.findVideoView(TUICallState.instance.selfUser.get().id)
+                EngineManager.instance.openCamera(camera, videoView?.getVideoView(), null)
+
+                if (TUICallState.instance.scene.get() == TUICallDefine.Scene.GROUP_CALL) {
+                    if (TUICallState.instance.showLargeViewUserId.get() != TUICallState.instance.selfUser.get().id) {
+                        TUICallState.instance.showLargeViewUserId.set(TUICallState.instance.selfUser.get().id)
+                    }
+                }
+            }
+        }
+
+        imageHangup?.setOnClickListener { EngineManager.instance.hangup(null) }
+
+        imageExpandView?.setOnClickListener() {
+            TUICallState.instance.isBottomViewExpand.set(!TUICallState.instance.isBottomViewExpand.get())
+        }
+
+        imageBlurBackground?.setOnClickListener {
+            EngineManager.instance.setBlurBackground(!TUICallState.instance.enableBlurBackground.get())
+        }
+
+        imageSwitchCamera?.setOnClickListener() {
+            var camera = TUICommonDefine.Camera.Back
+            if (TUICallState.instance.isFrontCamera.get() == TUICommonDefine.Camera.Back) {
+                camera = TUICommonDefine.Camera.Front
+            }
+            EngineManager.instance.switchCamera(camera)
+        }
+
+        rootLayout?.addTransitionListener(object : MotionLayout.TransitionListener {
+            override fun onTransitionStarted(motionLayout: MotionLayout, startId: Int, endId: Int) {
+                rootLayout?.background = context.resources.getDrawable(R.drawable.tuicallkit_bg_group_call_bottom)
+            }
+
+            override fun onTransitionChange(motionLayout: MotionLayout, startId: Int, endId: Int, progress: Float) {}
+
+            override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) {
+                rootLayout?.getConstraintSet(R.id.start)?.getConstraint(R.id.iv_expanded)?.propertySet?.visibility =
+                    VISIBLE
+            }
+
+            override fun onTransitionTrigger(motionLayout: MotionLayout, id: Int, positive: Boolean, progress: Float) {}
+        })
+    }
+
+    private fun updateView(isExpand: Boolean) {
+        if (TUICallState.instance.scene?.get() == TUICallDefine.Scene.SINGLE_CALL) {
+            return
+        }
+        if (isExpand) {
+            rootLayout?.transitionToStart()
+            rootLayout?.getConstraintSet(R.id.start)?.getConstraint(R.id.iv_expanded)?.propertySet?.visibility = VISIBLE
+        } else {
+            rootLayout?.transitionToEnd()
+        }
+    }
+}

+ 116 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/function/VideoCallerWaitingView.kt

@@ -0,0 +1,116 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.function
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class VideoCallerWaitingView(context: Context) : BaseCallView(context) {
+    private var layoutCancel: LinearLayout? = null
+    private var imageSwitchCamera: ImageView? = null
+    private var imageViewBlur: ImageView? = null
+    private var imageOpenCamera: ImageView? = null
+    private var layoutBlurBackground: LinearLayout? = null
+    private var textCamera: TextView? = null
+
+    private var enableBlurBackgroundObserver = Observer<Boolean> {
+        imageViewBlur?.isActivated = TUICallState.instance.enableBlurBackground.get()
+    }
+
+    private var isCameraOpenObserver = Observer<Boolean> {
+        imageOpenCamera?.isActivated = TUICallState.instance.isCameraOpen.get()
+    }
+
+    init {
+        initView()
+        TUICallState.instance.enableBlurBackground.observe(enableBlurBackgroundObserver)
+        TUICallState.instance.isCameraOpen.observe(isCameraOpenObserver)
+    }
+
+    override fun clear() {
+        TUICallState.instance.enableBlurBackground.removeObserver(enableBlurBackgroundObserver)
+        TUICallState.instance.isCameraOpen.removeObserver(isCameraOpenObserver)
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_function_view_video_inviting, this)
+        layoutCancel = findViewById(R.id.ll_cancel)
+        imageSwitchCamera = findViewById(R.id.img_switch_camera)
+        imageOpenCamera = findViewById(R.id.img_camera)
+        textCamera = findViewById(R.id.tv_camera)
+        layoutBlurBackground = findViewById(R.id.ll_blur)
+        imageViewBlur = findViewById(R.id.iv_video_blur)
+
+        imageOpenCamera?.isActivated = TUICallState.instance.isCameraOpen.get()
+
+        if (!TUICallState.instance.showVirtualBackgroundButton) {
+            layoutBlurBackground?.visibility = GONE
+            reLayoutView()
+        }
+
+        initViewListener()
+    }
+
+    private fun reLayoutView() {
+        val constraintLayout: ConstraintLayout = findViewById(R.id.constraint_layout)
+        val constraintSet: ConstraintSet = ConstraintSet()
+        constraintSet.clone(constraintLayout)
+
+        constraintSet.connect(R.id.ll_cancel, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+
+        constraintSet.connect(R.id.ll_switch, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+        constraintSet.connect(R.id.ll_switch, ConstraintSet.END, R.id.ll_cancel, ConstraintSet.START)
+        constraintSet.connect(R.id.ll_switch, ConstraintSet.TOP, R.id.ll_cancel, ConstraintSet.TOP)
+        constraintSet.connect(R.id.ll_switch, ConstraintSet.BOTTOM, R.id.ll_cancel, ConstraintSet.BOTTOM)
+
+        constraintSet.connect(R.id.ll_camera, ConstraintSet.START, R.id.ll_cancel, ConstraintSet.END)
+        constraintSet.connect(R.id.ll_camera, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+        constraintSet.connect(R.id.ll_camera, ConstraintSet.TOP, R.id.ll_cancel, ConstraintSet.TOP)
+        constraintSet.connect(R.id.ll_camera, ConstraintSet.BOTTOM, R.id.ll_cancel, ConstraintSet.BOTTOM)
+
+        constraintSet.applyTo(constraintLayout)
+    }
+
+    private fun initViewListener() {
+        layoutCancel?.setOnClickListener { EngineManager.instance.hangup(null) }
+        imageSwitchCamera!!.setOnClickListener {
+            var camera = TUICommonDefine.Camera.Back
+            if (TUICallState.instance.isFrontCamera.get() == TUICommonDefine.Camera.Back) {
+                camera = TUICommonDefine.Camera.Front
+            }
+            EngineManager.instance.switchCamera(camera)
+        }
+        imageOpenCamera?.setOnClickListener {
+            if (TUICallState.instance.isCameraOpen.get()) {
+                EngineManager.instance.closeCamera()
+
+                imageOpenCamera?.setImageResource(R.drawable.tuicallkit_ic_camera_disable)
+                textCamera?.text = context.resources.getString(R.string.tuicallkit_toast_disable_camera)
+                imageSwitchCamera?.isEnabled = false
+                layoutBlurBackground?.isEnabled = false
+            } else {
+                val camera = TUICallState.instance.isFrontCamera.get()
+                val videoView = VideoViewFactory.instance.findVideoView(TUICallState.instance.selfUser.get().id)
+                EngineManager.instance.openCamera(camera, videoView?.getVideoView(), null)
+
+                imageOpenCamera?.setImageResource(R.drawable.tuicallkit_ic_camera_enable)
+                textCamera?.text = context.resources.getString(R.string.tuicallkit_toast_enable_camera)
+                imageSwitchCamera?.isEnabled = true
+                layoutBlurBackground?.isEnabled = true
+            }
+        }
+        layoutBlurBackground?.setOnClickListener {
+            EngineManager.instance.setBlurBackground(!TUICallState.instance.enableBlurBackground.get())
+        }
+    }
+}

+ 15 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingCallReceiver.kt

@@ -0,0 +1,15 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+
+class IncomingCallReceiver : BroadcastReceiver() {
+    override fun onReceive(context: Context, intent: Intent?) {
+        if (intent != null && intent.action == Constants.REJECT_CALL_ACTION) {
+            EngineManager.instance.reject(null)
+        }
+    }
+}

+ 190 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingFloatView.kt

@@ -0,0 +1,190 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview
+
+import android.content.Context
+import android.content.Context.WINDOW_SERVICE
+import android.content.res.Configuration
+import android.graphics.PixelFormat
+import android.os.Build
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.widget.ImageView
+import android.widget.RelativeLayout
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.interfaces.ITUINotification
+import com.tencent.qcloud.tuicore.permission.PermissionCallback
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.utils.PermissionRequest
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.VideoViewFactory
+import com.trtc.tuikit.common.livedata.Observer
+
+class IncomingFloatView(context: Context) : RelativeLayout(context) {
+    companion object {
+        private const val TAG = "IncomingViewFloat"
+    }
+
+    private var appContext: Context = context.applicationContext
+    private var caller: User? = null
+
+    private var windowManager: WindowManager? = null
+    private var windowLayoutParams: WindowManager.LayoutParams? = null
+
+    private lateinit var layoutView: View
+    private var imageFloatAvatar: ImageView? = null
+    private var textFloatTitle: TextView? = null
+    private var textFloatDescription: TextView? = null
+    private var imageReject: ImageView? = null
+    private var imageAccept: ImageView? = null
+
+    private val padding = 40
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.None || it == TUICallDefine.Status.Accept) {
+            cancelIncomingView()
+        }
+    }
+
+    private val notification = ITUINotification { key, subKey, param ->
+        if (key == Constants.EVENT_VIEW_STATE_CHANGED &&
+            (subKey == Constants.EVENT_SHOW_FULL_VIEW || subKey == Constants.EVENT_SHOW_FLOAT_VIEW)) {
+            cancelIncomingView()
+        }
+    }
+
+    fun showIncomingView(user: User) {
+        Logger.info(TAG, "showIncomingView, user: $user")
+        caller = user
+        initWindow()
+        addObserver()
+    }
+
+    fun cancelIncomingView() {
+        Logger.info(TAG, "cancelIncomingView")
+        if (layoutView.isAttachedToWindow) {
+            windowManager?.removeView(layoutView)
+        }
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICore.registerEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FULL_VIEW, notification)
+        TUICore.registerEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FLOAT_VIEW, notification)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICore.unRegisterEvent(notification)
+    }
+
+    private fun initWindow() {
+        layoutView = LayoutInflater.from(context).inflate(R.layout.tuicallkit_incoming_float_view, this)
+        imageFloatAvatar = layoutView.findViewById(R.id.img_float_avatar)
+        textFloatTitle = layoutView.findViewById(R.id.tv_float_title)
+        textFloatDescription = layoutView.findViewById(R.id.tv_float_desc)
+        imageReject = layoutView.findViewById(R.id.btn_float_decline)
+        imageAccept = layoutView.findViewById(R.id.btn_float_accept)
+
+        ImageLoader.loadImage(appContext, imageFloatAvatar, caller?.avatar?.get(), R.drawable.tuicallkit_ic_avatar)
+        textFloatTitle?.text = caller?.nickname?.get()
+
+        textFloatDescription?.text = if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Video) {
+            appContext.resources.getString(R.string.tuicallkit_invite_video_call)
+        } else {
+            appContext.resources.getString(R.string.tuicallkit_invite_audio_call)
+        }
+
+        imageReject?.setOnClickListener {
+            EngineManager.instance.reject(null)
+            cancelIncomingView()
+        }
+
+        layoutView.setOnClickListener {
+            cancelIncomingView()
+            TUICore.notifyEvent(Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_ACTIVITY, HashMap())
+        }
+
+        if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Video) {
+            imageAccept?.setBackgroundResource(R.drawable.tuicallkit_ic_dialing_video)
+        } else {
+            imageAccept?.setBackgroundResource(R.drawable.tuicallkit_bg_dialing)
+        }
+        imageAccept?.setOnClickListener {
+            if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.None) {
+                Logger.warn(TAG, "current status is None, ignore")
+                cancelIncomingView()
+                return@setOnClickListener
+            }
+
+            PermissionRequest.requestPermissions(appContext, TUICallState.instance.mediaType.get(),
+                object : PermissionCallback() {
+                    override fun onGranted() {
+                        if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.None) {
+                            Logger.warn(TAG, "current status is None, ignore")
+                            cancelIncomingView()
+                            return
+                        }
+                        Logger.info(TAG, "accept the call")
+                        TUICore.notifyEvent(
+                            Constants.EVENT_TUICALLKIT_CHANGED, Constants.EVENT_START_ACTIVITY, HashMap()
+                        )
+                        EngineManager.instance.accept(null)
+                        if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Video) {
+                            val videoView = VideoViewFactory.instance.createVideoView(
+                                TUICallState.instance.selfUser.get(), appContext
+                            )
+
+                            EngineManager.instance.openCamera(
+                                TUICallState.instance.isFrontCamera.get(), videoView?.getVideoView(), null
+                            )
+                        }
+                        cancelIncomingView()
+                    }
+
+                    override fun onDenied() {
+                        super.onDenied()
+                        EngineManager.instance.reject(null)
+                        cancelIncomingView()
+                    }
+                })
+        }
+
+        windowManager = appContext.getSystemService(WINDOW_SERVICE) as WindowManager
+        windowManager?.addView(layoutView, viewParams)
+    }
+
+    private val viewParams: WindowManager.LayoutParams
+        get() {
+            windowLayoutParams = WindowManager.LayoutParams()
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                windowLayoutParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+            } else {
+                windowLayoutParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
+            }
+            windowLayoutParams!!.flags = (WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+                    or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+                    or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
+            windowLayoutParams!!.gravity = Gravity.START or Gravity.TOP
+            windowLayoutParams!!.x = padding
+            windowLayoutParams!!.y = 0
+            windowLayoutParams!!.width = ScreenUtil.getScreenWidth(appContext) - padding * 2
+            windowLayoutParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
+            windowLayoutParams!!.format = PixelFormat.TRANSPARENT
+            return windowLayoutParams as WindowManager.LayoutParams
+        }
+
+    override fun onConfigurationChanged(newConfig: Configuration?) {
+        super.onConfigurationChanged(newConfig)
+        layoutView?.let { windowManager?.updateViewLayout(layoutView, viewParams) }
+    }
+}

+ 160 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/incomingview/IncomingNotificationView.kt

@@ -0,0 +1,160 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.incomingview
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Build
+import android.widget.RemoteViews
+import androidx.core.app.NotificationCompat
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import com.bumptech.glide.request.RequestOptions
+import com.bumptech.glide.request.target.SimpleTarget
+import com.bumptech.glide.request.transition.Transition
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine.MediaType
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.extensions.NotificationFeature
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+import com.tencent.qcloud.tuikit.tuicallkit.view.CallKitActivity
+import com.trtc.tuikit.common.livedata.Observer
+
+class IncomingNotificationView(context: Context) {
+    private val TAG = "IncomingViewNotification"
+
+    private val notificationId = 9909
+
+    private val context: Context
+    private var remoteViews: RemoteViews? = null
+    private var notificationManager: NotificationManager
+    private var notification: Notification? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it != TUICallDefine.Status.Waiting) {
+            cancelNotification()
+        }
+    }
+
+    init {
+        this.context = context.applicationContext
+        notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+    }
+
+    fun showNotification(user: User) {
+        Logger.info(TAG, "showNotification, user: $user")
+        addObserver()
+        notification = createNotification()
+
+        if (user.nickname.get().isNullOrEmpty()) {
+            remoteViews?.setTextViewText(R.id.tv_incoming_title, user.id)
+        } else {
+            remoteViews?.setTextViewText(R.id.tv_incoming_title, user.nickname.get())
+        }
+
+        val mediaType = TUICallState.instance.mediaType.get()
+        if (mediaType == MediaType.Video) {
+            remoteViews?.setTextViewText(R.id.tv_desc, context.getString(R.string.tuicallkit_invite_video_call))
+            remoteViews?.setImageViewResource(R.id.img_media_type, R.drawable.tuicallkit_ic_video_incoming)
+            remoteViews?.setImageViewResource(R.id.btn_accept, R.drawable.tuicallkit_ic_dialing_video)
+        } else {
+            remoteViews?.setTextViewText(R.id.tv_desc, context.getString(R.string.tuicallkit_invite_audio_call))
+            remoteViews?.setImageViewResource(R.id.img_media_type, R.drawable.tuicallkit_ic_float)
+            remoteViews?.setImageViewResource(R.id.btn_accept, R.drawable.tuicallkit_bg_dialing)
+        }
+
+        if (user.avatar.get().isNullOrEmpty()) {
+            remoteViews?.setImageViewResource(R.id.img_incoming_avatar, R.drawable.tuicallkit_ic_avatar)
+            notificationManager.notify(notificationId, notification)
+        } else {
+            val uri = Uri.parse(user.avatar.get())
+
+            Glide.with(context).asBitmap().load(uri)
+                .diskCacheStrategy(DiskCacheStrategy.ALL)
+                .placeholder(R.drawable.tuicallkit_ic_avatar)
+                .apply(RequestOptions.bitmapTransform(RoundedCorners(15)))
+                .into(object : SimpleTarget<Bitmap>() {
+                    override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
+                        remoteViews?.setImageViewBitmap(R.id.img_incoming_avatar, resource)
+                        notificationManager.notify(notificationId, notification)
+                    }
+
+                    override fun onLoadFailed(errorDrawable: Drawable?) {
+                        remoteViews?.setImageViewResource(R.id.img_incoming_avatar, R.drawable.tuicallkit_ic_avatar)
+                        notificationManager.notify(notificationId, notification)
+                    }
+                })
+        }
+    }
+
+    fun cancelNotification() {
+        Logger.info(TAG, "cancelNotification")
+        notificationManager.cancel(notificationId)
+        removeObserver()
+    }
+
+    private fun createNotification(): Notification {
+        val channelId = NotificationFeature.CALL_CHANNEL_ID
+        val builder = NotificationCompat.Builder(context, channelId)
+            .setOngoing(true)
+            .setWhen(System.currentTimeMillis())
+            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .setTimeoutAfter(Constants.SIGNALING_MAX_TIME * 1000L)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            builder.setCategory(NotificationCompat.CATEGORY_CALL)
+            builder.priority = NotificationCompat.PRIORITY_MAX
+        }
+
+        builder.setChannelId(channelId)
+        builder.setTimeoutAfter(Constants.SIGNALING_MAX_TIME * 1000L)
+        builder.setSmallIcon(R.drawable.tuicallkit_ic_avatar)
+        builder.setSound(null)
+
+        builder.setContentIntent(getPendingIntent())
+        builder.setFullScreenIntent(getPendingIntent(), true)
+
+        remoteViews = RemoteViews(context.packageName, R.layout.tuicallkit_incoming_notification_view)
+        remoteViews?.setOnClickPendingIntent(R.id.btn_decline, getDeclineIntent())
+        remoteViews?.setOnClickPendingIntent(R.id.btn_accept, getAcceptIntent())
+
+        builder.setCustomContentView(remoteViews)
+        builder.setCustomBigContentView(remoteViews)
+        return builder.build()
+    }
+
+    private fun getPendingIntent(): PendingIntent {
+        val intent = Intent(context, CallKitActivity::class.java)
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+    }
+
+    private fun getDeclineIntent(): PendingIntent {
+        val intent = Intent(context, IncomingCallReceiver::class.java)
+        intent.action = Constants.REJECT_CALL_ACTION
+        return PendingIntent.getBroadcast(context, 1, intent, PendingIntent.FLAG_IMMUTABLE)
+    }
+
+    private fun getAcceptIntent(): PendingIntent {
+        val intent = Intent(context, CallKitActivity::class.java)
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        intent.action = Constants.ACCEPT_CALL_ACTION
+        return PendingIntent.getActivity(context, 2, intent, PendingIntent.FLAG_IMMUTABLE)
+    }
+}

+ 47 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/group/GroupCallerUserInfoView.kt

@@ -0,0 +1,47 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.group
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+
+class GroupCallerUserInfoView(context: Context) : BaseCallView(context) {
+    private var imageAvatar: ImageView? = null
+    private var textUserName: TextView? = null
+    private var user = findCaller()
+
+    init {
+        initView()
+    }
+
+    private fun findCaller(): User? {
+        for (user in TUICallState.instance.remoteUserList.get()) {
+            if (TUICallDefine.Role.Caller == user.callRole.get()) {
+                return user
+            }
+        }
+        return null
+    }
+
+    override fun clear() {}
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_user_info_group_caller, this)
+        imageAvatar = findViewById(R.id.img_avatar)
+        textUserName = findViewById(R.id.tv_name)
+
+        ImageLoader.loadImage(context, imageAvatar, user?.avatar?.get(), R.drawable.tuicallkit_ic_avatar)
+        textUserName!!.text = if (TextUtils.isEmpty(user?.nickname?.get())) {
+            user?.id
+        } else {
+            user?.nickname?.get()
+        }
+    }
+}

+ 119 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/group/InviteeAvatarListView.kt

@@ -0,0 +1,119 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.group
+
+import android.content.Context
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.trtc.tuikit.common.livedata.LiveData
+import com.trtc.tuikit.common.livedata.Observer
+import java.util.concurrent.CopyOnWriteArrayList
+
+class InviteeAvatarListView(context: Context) : LinearLayout(context) {
+
+    private var inviteeUserList = LiveData<CopyOnWriteArrayList<User>>()
+
+    private var remoteUserListObserver = Observer<LinkedHashSet<User>> {
+        if (it != null && it.size > 0) {
+            for (user in it) {
+                if (!inviteeUserList.get().contains(user) && TUICallDefine.Role.Called == user.callRole.get()) {
+                    inviteeUserList.add(user)
+                }
+            }
+            for ((index, user) in inviteeUserList.get().withIndex()) {
+                if (index == 0) {
+                    continue
+                }
+                if (!it.contains(user)) {
+                    inviteeUserList.remove(user)
+                }
+            }
+        }
+        removeAllViews()
+        initView()
+    }
+
+
+    private var avatarObserver = Observer<String> {
+        removeAllViews()
+        initView()
+    }
+
+    init {
+        inviteeUserList.set(CopyOnWriteArrayList())
+        inviteeUserList.get().add(TUICallState.instance.selfUser.get())
+        for (user in TUICallState.instance.remoteUserList.get()) {
+            if (TUICallDefine.Role.Called == user.callRole.get()) {
+                inviteeUserList.get().add(user)
+            }
+        }
+
+        orientation = VERTICAL
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.remoteUserList.observe(remoteUserListObserver)
+        for (user in inviteeUserList.get()) {
+            user.avatar.observe(avatarObserver)
+        }
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.remoteUserList.removeObserver(remoteUserListObserver)
+        for (user in inviteeUserList.get()) {
+            user.avatar.removeObserver(avatarObserver)
+        }
+    }
+
+    private fun initView() {
+        if (inviteeUserList == null || inviteeUserList.get().isNullOrEmpty()) {
+            return
+        }
+        addView(createTextView())
+
+        val layoutAvatar = LinearLayout(context)
+        layoutAvatar.layoutParams =
+            LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        addView(layoutAvatar)
+
+        val squareWidth = context.resources.getDimensionPixelOffset(R.dimen.tuicallkit_small_image_size)
+        val leftMargin = context.resources.getDimensionPixelOffset(R.dimen.tuicallkit_small_image_left_margin)
+        for ((index, user) in inviteeUserList?.get()!!.withIndex()) {
+            val imageView = ImageFilterView(context)
+            val layoutParams = LayoutParams(squareWidth, squareWidth)
+            if (index != 0) {
+                layoutParams.marginStart = leftMargin
+            }
+            imageView.round = 12f
+            imageView.scaleType = ImageView.ScaleType.CENTER_CROP
+            imageView.layoutParams = layoutParams
+            ImageLoader.loadImage(context, imageView, user!!.avatar.get(), R.drawable.tuicallkit_ic_avatar)
+            layoutAvatar.addView(imageView)
+        }
+    }
+
+    private fun createTextView(): TextView {
+        val textView = TextView(context)
+        textView.text = context.getString(R.string.tuicallkit_invitee_user_list)
+        textView.textSize = 12f
+        textView.setTextColor(context.resources.getColor(R.color.tuicallkit_color_white))
+        val param = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        param.bottomMargin = 24
+        param.gravity = Gravity.CENTER
+        textView.layoutParams = param
+        return textView
+    }
+}

+ 66 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/single/AudioCallUserInfoView.kt

@@ -0,0 +1,66 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.single
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class AudioCallUserInfoView(context: Context) : BaseCallView(context) {
+    private var imageBackground: ImageView? = null
+    private var imageAvatar: ImageView? = null
+    private var textUserName: TextView? = null
+    private var userModel = TUICallState.instance.remoteUserList.get().first()
+
+    private var avatarObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            ImageLoader.loadImage(context.applicationContext, imageAvatar, it, R.drawable.tuicallkit_ic_avatar)
+        }
+        setBackground()
+    }
+
+    private var nicknameObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            textUserName?.text = it
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_user_info_audio, this)
+        imageBackground = findViewById(R.id.img_user_background)
+        imageAvatar = findViewById(R.id.img_avatar)
+        textUserName = findViewById(R.id.tv_name)
+        ImageLoader.loadImage(context, imageAvatar, userModel.avatar.get(), R.drawable.tuicallkit_ic_avatar)
+        textUserName!!.text = userModel.nickname.get()
+
+        setBackground()
+    }
+
+    private fun setBackground() {
+        ImageLoader.loadBlurImage(context, imageBackground, userModel.avatar.get())
+    }
+
+    private fun addObserver() {
+        userModel.avatar.observe(avatarObserver)
+        userModel.nickname.observe(nicknameObserver)
+    }
+
+    private fun removeObserver() {
+        userModel.avatar.removeObserver(avatarObserver)
+        userModel.nickname.removeObserver(nicknameObserver)
+    }
+}

+ 86 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/userinfo/single/VideoCallUserInfoView.kt

@@ -0,0 +1,86 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.single
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.trtc.tuikit.common.livedata.Observer
+
+class VideoCallUserInfoView(context: Context) : BaseCallView(context) {
+    private var imageAvatar: ImageView? = null
+    private var textUserName: TextView? = null
+    private var userModel = TUICallState.instance.remoteUserList.get().first()
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.Accept) {
+            this.visibility = GONE
+        } else if (it == TUICallDefine.Status.Waiting) {
+            this.visibility = VISIBLE
+        }
+    }
+
+    private var mediaTypeObserver = Observer<TUICallDefine.MediaType> {
+        if (it == TUICallDefine.MediaType.Audio) {
+            this.visibility = GONE
+        } else {
+            this.visibility = VISIBLE
+        }
+    }
+
+    private var avatarObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            ImageLoader.loadImage(context.applicationContext, imageAvatar, it, R.drawable.tuicallkit_ic_avatar)
+        }
+    }
+
+    private var nicknameObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            textUserName?.text = it
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_user_info_video, this)
+        imageAvatar = findViewById(R.id.iv_user_avatar)
+        textUserName = findViewById(R.id.tv_user_name)
+        ImageLoader.loadImage(context, imageAvatar, userModel.avatar.get(), R.drawable.tuicallkit_ic_avatar)
+        textUserName!!.text = userModel.nickname.get()
+
+        if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Accept
+            || TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Audio
+        ) {
+            this.visibility = GONE
+        } else {
+            this.visibility = VISIBLE
+        }
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICallState.instance.mediaType.observe(mediaTypeObserver)
+        userModel.avatar.observe(avatarObserver)
+        userModel.nickname.observe(nicknameObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICallState.instance.mediaType.removeObserver(mediaTypeObserver)
+        userModel.avatar.removeObserver(avatarObserver)
+        userModel.nickname.removeObserver(nicknameObserver)
+    }
+}

+ 309 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/GroupCallFlowLayout.kt

@@ -0,0 +1,309 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout
+
+import android.content.Context
+import android.content.res.Configuration
+import android.view.View
+import android.widget.RelativeLayout
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+
+open class GroupCallFlowLayout(context: Context) : RelativeLayout(context) {
+    companion object {
+        const val TAG = "GroupCallFlowLayout"
+        const val DEFAULT_INDEX: Int = -99
+    }
+
+    public var showLargeViewIndex = DEFAULT_INDEX
+    private var measureWidth: Int = 0
+    private var screenWidth: Int = 0
+    private var screenHeight: Int = 0
+    private val changeList = ArrayList<View>()
+    private var startMargin: Int = 0
+
+    init {
+        setViewWidth()
+    }
+
+    override fun onConfigurationChanged(newConfig: Configuration?) {
+        super.onConfigurationChanged(newConfig)
+        setViewWidth()
+        requestLayout()
+    }
+
+    private fun setViewWidth() {
+        screenWidth = ScreenUtil.getRealScreenWidth(context)
+        screenHeight = ScreenUtil.getRealScreenHeight(context)
+        measureWidth = screenWidth
+        startMargin = 0
+
+        val isLandScape = when (TUICallState.instance.orientation) {
+            Constants.Orientation.Portrait -> false
+            Constants.Orientation.LandScape -> true
+            else -> ScreenUtil.getRealScreenWidth(context) > ScreenUtil.getRealScreenHeight(context)
+        }
+
+        if (isLandScape) {
+            // Set grid's actual width to 0.6 times the minimum value of width and height in landscape mode
+            measureWidth = (screenHeight * 0.6).toInt()
+            startMargin = (screenWidth - measureWidth) / 2
+        }
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        val width = MeasureSpec.makeMeasureSpec(screenWidth, MeasureSpec.EXACTLY)
+        var height = MeasureSpec.makeMeasureSpec(measureWidth + measureWidth / 3, MeasureSpec.EXACTLY)
+
+        if (showLargeViewIndex < 0) {
+            height = if (childCount <= 2) {
+                MeasureSpec.makeMeasureSpec(measureWidth / 2 + getTopMargin(childCount), MeasureSpec.EXACTLY)
+            } else {
+                MeasureSpec.makeMeasureSpec(measureWidth, MeasureSpec.EXACTLY)
+            }
+        }
+        setMeasuredDimension(width, height)
+
+        for (i in 0 until childCount) {
+            val child: View = getChildAt(i)
+            val childSize = MeasureSpec.makeMeasureSpec(getMeasureSize(i, childCount), MeasureSpec.EXACTLY)
+            measureChild(child, childSize, childSize)
+        }
+    }
+
+    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+        val params = getLocation(childCount)
+
+        if (showLargeViewIndex > 0 && childCount <= 4) {
+            for (i in 0 until childCount) {
+                val child = getChildAt(i)
+                if (i != showLargeViewIndex) {
+                    changeList.add(child)
+                } else {
+                    changeList.add(0, child)
+                }
+            }
+
+            for (i in 0 until changeList.size) {
+                val view = changeList[i]
+                val pos = params[i]
+                view.layout(pos.x, pos.y, view.measuredWidth + pos.x, view.measuredHeight + pos.y)
+            }
+            changeList.clear()
+            return
+        }
+
+        val topMargin = getTopMargin(childCount)
+
+        for (i in 0 until childCount) {
+            val child = getChildAt(i)
+            val pos = params[i]
+            child.layout(
+                pos.x, pos.y + topMargin,
+                child.measuredWidth + pos.x, child.measuredHeight + pos.y + topMargin
+            )
+        }
+    }
+
+    public fun setLargeViewIndex(index: Int) {
+        showLargeViewIndex = if (index == showLargeViewIndex) {
+            DEFAULT_INDEX
+        } else {
+            index
+        }
+        requestLayout()
+    }
+
+    private fun getMeasureSize(index: Int, count: Int): Int {
+        return when {
+            count <= 4 && showLargeViewIndex == index -> measureWidth
+            count <= 4 && showLargeViewIndex < 0 -> measureWidth / 2
+            count > 4 && showLargeViewIndex == index -> measureWidth / 3 * 2
+            else -> measureWidth / 3
+        }
+    }
+
+    private fun getTopMargin(count: Int): Int {
+        return if (count <= 2 && showLargeViewIndex < 0 && screenWidth < screenHeight) {
+            measureWidth / 4
+        } else {
+            0
+        }
+    }
+
+    private fun getLocation(count: Int): List<Position> {
+        val width = measureWidth / 3
+        val height = measureWidth / 3
+
+        var currentIndex = 0
+        var lastFrame = 0
+        var segment: SegmentStyle = getSegment(count, currentIndex)
+
+        val list = ArrayList<Position>()
+        while (currentIndex < count) {
+            when (segment) {
+                SegmentStyle.FULL_WIDTH -> {
+                    list.add(fullWidth(startMargin, lastFrame, width, height))
+                    lastFrame += height * 3
+                    currentIndex += 1
+                }
+                SegmentStyle.FIFTY_FIFTY -> {
+                    list.addAll(fiftyFifty(startMargin, lastFrame, width, height))
+                    lastFrame += height * 3
+                    currentIndex += 4
+                }
+                SegmentStyle.THREE_ONE_THIRDS -> {
+                    list.addAll(threeOneThird(startMargin, lastFrame, width, height))
+                    lastFrame += height
+                    currentIndex += 3
+                }
+                SegmentStyle.TWO_THIRDS_ONE_THIRD_CENTER -> {
+                    list.addAll(twoThirdsOneThirdCenter(startMargin, lastFrame, width, height))
+                    lastFrame += height * 2
+                    currentIndex += 3
+                }
+                SegmentStyle.TWO_THIRDS_ONE_THIRD_RIGHT -> {
+                    list.addAll(twoThirdsOneThirdRight(startMargin, lastFrame, width, height))
+                    lastFrame += height * 2
+                    currentIndex += 3
+                }
+                SegmentStyle.ONE_THIRD_TWO_THIRDS -> {
+                    list.addAll(oneThirdTwoThirds(startMargin, lastFrame, width, height))
+                    lastFrame += height * 2
+                    currentIndex += 3
+                }
+                SegmentStyle.ONE_THIRD -> {
+                    list.addAll(oneThird(startMargin, lastFrame, width, height))
+                    lastFrame += height * 3
+                    currentIndex += 3
+                }
+                else -> {}
+            }
+            segment = getSegment(count, currentIndex)
+        }
+        return list
+    }
+
+    private fun oneThirdTwoThirds(x: Int, y: Int, width: Int, height: Int): List<Position> {
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width * 2, height * 2))
+        list.add(Position(x + width * 2, y, width, height))
+        list.add(Position(x + width * 2, y + height, width, height))
+        return list
+    }
+
+    private fun threeOneThird(x: Int, y: Int, width: Int, height: Int): List<Position> {
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width, height))
+        list.add(Position(x + width, y, width, height))
+        list.add(Position(x + width * 2, y, width, height))
+        return list
+    }
+
+    private fun fullWidth(x: Int, y: Int, width: Int, height: Int): Position = Position(x, y, width * 3, height * 3)
+
+    private fun twoThirdsOneThirdCenter(x: Int, y: Int, width: Int, height: Int): List<Position> {
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width, height))
+        list.add(Position(x + width, y, width * 2, height * 2))
+        list.add(Position(x, y + height, width, height))
+        return list
+    }
+
+    private fun twoThirdsOneThirdRight(x: Int, y: Int, width: Int, height: Int): List<Position> {
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width, height))
+        list.add(Position(x, y + height, width, height))
+        list.add(Position(x + width, y, width * 2, height * 2))
+        return list
+    }
+
+    private fun fiftyFifty(x: Int, y: Int, childWidth: Int, childHeight: Int): List<Position> {
+        val width = childWidth * 3 / 2
+        val height = childHeight * 3 / 2
+
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width, height))
+        list.add(Position(x + width, y, width, height))
+        list.add(Position(x, y + height, width, height))
+        list.add(Position(x + width, y + height, width, height))
+        return list
+    }
+
+    private fun oneThird(x: Int, y: Int, childWidth: Int, childHeight: Int): List<Position> {
+        val width = childWidth * 3 / 2
+        val height = childHeight * 3 / 2
+
+        val list = ArrayList<Position>()
+        list.add(Position(x, y, width, height))
+        list.add(Position(x + width, y, width, height))
+        list.add(Position(x + width / 2, y + height, width, height))
+        return list
+    }
+
+    private fun getSegment(count: Int, currentIndex: Int): SegmentStyle {
+        var segment = SegmentStyle.THREE_ONE_THIRDS
+        if (currentIndex == 0) {
+            when {
+                count == 1 -> segment = SegmentStyle.FULL_WIDTH
+                count == 2 || count == 4 -> segment =
+                    if (showLargeViewIndex >= 0) SegmentStyle.FULL_WIDTH else SegmentStyle.FIFTY_FIFTY
+                count == 3 -> segment =
+                    if (showLargeViewIndex >= 0) SegmentStyle.FULL_WIDTH else SegmentStyle.ONE_THIRD
+                showLargeViewIndex == 0 -> segment = SegmentStyle.ONE_THIRD_TWO_THIRDS
+                showLargeViewIndex == 1 -> segment = SegmentStyle.TWO_THIRDS_ONE_THIRD_CENTER
+                showLargeViewIndex == 2 -> segment = SegmentStyle.TWO_THIRDS_ONE_THIRD_RIGHT
+            }
+            return segment
+        }
+        when (count - currentIndex) {
+            1 -> segment = when {
+                count == 3 -> SegmentStyle.ONE_THIRD
+                count > 4 && showLargeViewIndex == count - 1 -> SegmentStyle.ONE_THIRD_TWO_THIRDS
+                count == 4 -> SegmentStyle.FIFTY_FIFTY
+                count > 4 && showLargeViewIndex == currentIndex -> SegmentStyle.ONE_THIRD_TWO_THIRDS
+                count > 4 && showLargeViewIndex == currentIndex + 1 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_CENTER
+                count > 4 && showLargeViewIndex == currentIndex + 2 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_RIGHT
+                else -> SegmentStyle.THREE_ONE_THIRDS
+            }
+            2 -> segment = when {
+                count == 4 -> SegmentStyle.FIFTY_FIFTY
+                count > 4 && showLargeViewIndex == currentIndex -> SegmentStyle.ONE_THIRD_TWO_THIRDS
+                count > 4 && showLargeViewIndex == currentIndex + 1 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_CENTER
+                count > 4 && showLargeViewIndex == currentIndex + 2 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_RIGHT
+                else -> SegmentStyle.THREE_ONE_THIRDS
+            }
+            else -> segment =
+                when {
+                    count > 4 && showLargeViewIndex == currentIndex -> SegmentStyle.ONE_THIRD_TWO_THIRDS
+                    count > 4 && showLargeViewIndex == currentIndex + 1 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_CENTER
+                    count > 4 && showLargeViewIndex == currentIndex + 2 -> SegmentStyle.TWO_THIRDS_ONE_THIRD_RIGHT
+                    else -> SegmentStyle.THREE_ONE_THIRDS
+                }
+        }
+        return segment
+    }
+
+    class Position(xx: Int, yy: Int, wWidth: Int, hHeight: Int) {
+        var x = 0
+        var y = 0
+        var width = 0
+        var height = 0
+
+        init {
+            x = xx
+            y = yy
+            width = wWidth
+            height = hHeight
+        }
+    }
+
+    enum class SegmentStyle {
+        FULL_WIDTH,
+        ONE_THIRD,
+        FIFTY_FIFTY,
+        THREE_ONE_THIRDS,
+        ONE_THIRD_TWO_THIRDS,
+        TWO_THIRDS_ONE_THIRD_CENTER,
+        TWO_THIRDS_ONE_THIRD_RIGHT
+    }
+}

+ 141 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/GroupCallVideoLayout.kt

@@ -0,0 +1,141 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout
+
+import android.content.Context
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.View.OnTouchListener
+import android.view.ViewGroup
+import android.widget.RelativeLayout
+import androidx.transition.TransitionManager
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout.GroupCallVideoLayoutViewModel
+import com.trtc.tuikit.common.livedata.Observer
+
+class GroupCallVideoLayout(context: Context) : GroupCallFlowLayout(context) {
+    private var viewModel = GroupCallVideoLayoutViewModel()
+    private var changedUserObserver = Observer<User> {
+        updateView(it)
+    }
+    private var showLargeViewUserIdObserver = Observer<String> {
+        var index = DEFAULT_INDEX
+        for (i in 0 until viewModel.userList.get().size) {
+            if (it == viewModel.userList.get()[i].id) {
+                index = i
+                break
+            }
+        }
+        TransitionManager.beginDelayedTransition(this)
+        setLargeViewIndex(index)
+        val isExpand = showLargeViewIndex != index || showLargeViewIndex == DEFAULT_INDEX
+        viewModel.updateBottomViewExpanded(isExpand)
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        removeObserver()
+        viewModel.removeObserver()
+    }
+
+    private fun addObserver() {
+        viewModel.changedUser.observe(changedUserObserver)
+        viewModel.showLargeViewUserId.observe(showLargeViewUserIdObserver)
+    }
+
+    private fun removeObserver() {
+        viewModel.changedUser.removeObserver(changedUserObserver)
+        viewModel.showLargeViewUserId.removeObserver(showLargeViewUserIdObserver)
+    }
+
+    private fun initView() {
+        removeAllViews()
+
+        for ((index, user) in viewModel.userList.get().withIndex()) {
+            val videoView = VideoViewFactory.instance.createVideoView(user, context)
+            if (videoView != null && videoView.parent != null) {
+                if (videoView.parent.parent != null) {
+                    (videoView.parent.parent as ViewGroup).removeAllViews()
+                }
+                (videoView.parent as RelativeLayout).removeView(videoView)
+            }
+
+            addView(videoView)
+            initGestureListener(user)
+
+            if (TUICallDefine.MediaType.Video == viewModel.mediaType.get()) {
+                if (index == 0) {
+                    if (TUICallState.instance.isCameraOpen.get()) {
+                        EngineManager.instance.openCamera(
+                            viewModel.isFrontCamera.get(), videoView?.getVideoView(), null
+                        )
+                        viewModel.updateShowLargeViewUserId(user.id)
+                    }
+                } else {
+                    EngineManager.instance.startRemoteView(user.id, videoView?.getVideoView(), null)
+                }
+            } else if (index != 0 && user.videoAvailable.get() == true) {
+                EngineManager.instance.startRemoteView(user.id, videoView?.getVideoView(), null)
+            }
+        }
+    }
+
+    private fun updateView(user: User) {
+        if (user.callStatus.get() == TUICallDefine.Status.None) {
+            var videoView = VideoViewFactory.instance.findVideoView(user.id)
+            if (videoView != null && videoView.parent != null) {
+                (videoView.parent as RelativeLayout).removeView(videoView)
+                videoView.clear()
+                videoView = null
+            }
+            post {
+                if (viewModel.showLargeViewUserId.get() == user.id) {
+                    TransitionManager.beginDelayedTransition(this)
+                    viewModel.updateShowLargeViewUserId(null)
+                    setLargeViewIndex(DEFAULT_INDEX)
+                    viewModel.updateBottomViewExpanded(true)
+                }
+            }
+            VideoViewFactory.instance.videoEntityList.remove(user.id)
+        } else {
+            val videoView = VideoViewFactory.instance.createVideoView(user, context)
+            if (videoView != null && videoView.parent != null) {
+                (videoView.parent as RelativeLayout).removeView(videoView)
+            }
+            addView(videoView)
+            initGestureListener(user)
+        }
+    }
+
+    private fun initGestureListener(user: User) {
+        val videoView = VideoViewFactory.instance.findVideoView(user.id)
+        videoView?.setOnClickListener() {
+            post {
+                val index: Int = viewModel.userList.get().indexOf(user)
+                val showLargeViewUserId = if (index == showLargeViewIndex) null else user.id
+                viewModel.updateShowLargeViewUserId(showLargeViewUserId)
+            }
+        }
+
+        val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+            override fun onSingleTapUp(e: MotionEvent): Boolean {
+                videoView?.performClick()
+                return false
+            }
+
+            override fun onDown(e: MotionEvent): Boolean {
+                return true
+            }
+
+            override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
+                return true
+            }
+        })
+        videoView?.setOnTouchListener(OnTouchListener { v, event -> detector.onTouchEvent(event) })
+    }
+}

+ 215 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/SingleCallVideoLayout.kt

@@ -0,0 +1,215 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Handler
+import android.os.Looper
+import android.os.Message
+import android.view.GestureDetector
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.RelativeLayout
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout.SingleCallVideoLayoutViewModel
+import com.trtc.tuikit.common.livedata.Observer
+
+class SingleCallVideoLayout(context: Context) : BaseCallView(context) {
+    private val MESSAGE_VIDEO_AVAIABLE_UPDATE = 2
+    private val UPDATE_INTERVAL: Long = 200
+    private val UPDATE_COUNT = 3
+    private var retryCount = 0
+
+    private var layoutRenderBig: RelativeLayout? = null
+    private var layoutRenderSmall: RelativeLayout? = null
+    private var videoViewSmall: VideoView? = null
+    private var videoViewBig: VideoView? = null
+    private var viewModel: SingleCallVideoLayoutViewModel = SingleCallVideoLayoutViewModel()
+
+    private val mainHandler: Handler = object : Handler(Looper.getMainLooper()) {
+        override fun handleMessage(msg: Message) {
+            super.handleMessage(msg)
+
+            val remoteUserVideoAvailable = viewModel.remoteUser.videoAvailable.get()
+            if (retryCount <= UPDATE_COUNT && !remoteUserVideoAvailable) {
+                sendEmptyMessageDelayed(MESSAGE_VIDEO_AVAIABLE_UPDATE, UPDATE_INTERVAL)
+                retryCount++
+            } else if (remoteUserVideoAvailable) {
+                retryCount = 0
+            } else {
+                videoViewSmall?.setImageAvatarVisibility(true)
+                retryCount = 0
+            }
+        }
+    }
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.Accept) {
+            initSmallRenderView()
+            videoViewSmall?.setImageAvatarVisibility(false)
+            switchRenderLayout()
+
+            mainHandler.sendEmptyMessageDelayed(MESSAGE_VIDEO_AVAIABLE_UPDATE, UPDATE_INTERVAL)
+        }
+    }
+
+    private var blurBackgroundObserver = Observer<Boolean> {
+        if (it == true && viewModel.currentReverseRenderView) {
+            switchRenderLayout()
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    private fun addObserver() {
+        viewModel.remoteUser.callStatus.observe(callStatusObserver)
+        viewModel.enableBlurBackground.observe(blurBackgroundObserver)
+    }
+
+    private fun removeObserver() {
+        viewModel.remoteUser.callStatus.removeObserver(callStatusObserver)
+        viewModel.enableBlurBackground.removeObserver(blurBackgroundObserver)
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_render_view_single, this)
+        layoutRenderBig = findViewById(R.id.rl_render_inviter)
+        layoutRenderBig?.setOnClickListener() {
+            viewModel.showFullScreen()
+        }
+        layoutRenderSmall = findViewById(R.id.rl_render_invitee)
+        layoutRenderSmall?.setOnClickListener {
+            switchRenderLayout()
+        }
+        initGestureListener(layoutRenderSmall)
+        initBigRenderView()
+        initSmallRenderView()
+        if (viewModel.lastReverseRenderView) {
+            switchRenderLayout()
+        }
+    }
+
+    private fun switchRenderLayout() {
+        if (viewModel.remoteUser.callStatus.get() == TUICallDefine.Status.Accept) {
+            if (videoViewSmall != null && videoViewSmall?.parent != null) {
+                var parent: RelativeLayout = videoViewSmall?.parent as RelativeLayout
+                parent.removeAllViews()
+                layoutRenderSmall?.removeAllViews()
+            }
+            if (videoViewBig != null && videoViewBig?.parent != null) {
+                var parent: RelativeLayout = videoViewBig?.parent as RelativeLayout
+                parent.removeAllViews()
+                layoutRenderBig?.removeAllViews()
+            }
+            setSmallRenderViewOrientation()
+            if (viewModel.currentReverseRenderView) {
+                viewModel.reverseRenderLayout(false)
+                layoutRenderSmall?.addView(videoViewSmall)
+                layoutRenderBig?.addView(videoViewBig)
+            } else {
+                viewModel.reverseRenderLayout(true)
+                layoutRenderSmall?.addView(videoViewBig)
+                layoutRenderBig?.addView(videoViewSmall)
+            }
+        }
+    }
+
+    private fun initSmallRenderView() {
+        if (viewModel.remoteUser.callStatus.get() == TUICallDefine.Status.Accept) {
+            videoViewSmall = VideoViewFactory.instance.createVideoView(viewModel.remoteUser, context)
+            if (videoViewSmall != null && videoViewSmall?.parent != null) {
+                (videoViewSmall?.parent as ViewGroup).removeView(videoViewSmall)
+                layoutRenderSmall?.removeAllViews()
+            }
+            setSmallRenderViewOrientation()
+            layoutRenderSmall?.addView(videoViewSmall)
+            EngineManager.instance.startRemoteView(viewModel.remoteUser.id, videoViewSmall?.getVideoView(), null)
+        }
+    }
+
+    private fun initBigRenderView() {
+        videoViewBig = VideoViewFactory.instance.createVideoView(viewModel.selfUser, context)
+        if (videoViewBig != null && videoViewBig?.parent != null) {
+            (videoViewBig?.parent as ViewGroup).removeView(videoViewBig)
+            layoutRenderBig?.removeAllViews()
+        }
+        layoutRenderBig?.addView(videoViewBig)
+        if (TUICallState.instance.isCameraOpen.get()) {
+            EngineManager.instance.openCamera(viewModel.isFrontCamera.get(), videoViewBig?.getVideoView(), null)
+        }
+    }
+
+    override fun onConfigurationChanged(newConfig: Configuration?) {
+        super.onConfigurationChanged(newConfig)
+        setSmallRenderViewOrientation()
+        layoutRenderSmall?.requestLayout()
+    }
+
+    private fun setSmallRenderViewOrientation() {
+        val isLandScape = when (TUICallState.instance.orientation) {
+            Constants.Orientation.Portrait -> false
+            Constants.Orientation.LandScape -> true
+            else -> ScreenUtil.getRealScreenWidth(context) > ScreenUtil.getRealScreenHeight(context)
+        }
+
+        val wWidth = context.resources.getDimension(R.dimen.tuicallkit_video_small_view_width).toInt()
+        val hHeight = context.resources.getDimension(R.dimen.tuicallkit_video_small_view_height).toInt()
+
+        val lp = layoutRenderSmall?.layoutParams
+        lp?.width = if (isLandScape) hHeight else wWidth
+        lp?.height = if (isLandScape) wWidth else hHeight
+        layoutRenderSmall?.layoutParams = lp
+    }
+
+    private fun initGestureListener(view: RelativeLayout?) {
+        val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+            override fun onSingleTapUp(e: MotionEvent): Boolean {
+                view!!.performClick()
+                return false
+            }
+
+            override fun onDown(e: MotionEvent): Boolean {
+                return true
+            }
+
+            override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
+                val params = view?.layoutParams
+                if (params is LayoutParams) {
+                    val offsetX = if (isRTL) (e2.x - (e1?.x ?: 0f)) else ((e1?.x ?: 0f) - e2.x)
+
+                    val layoutParams = view.layoutParams as LayoutParams
+                    val newX = (layoutParams.marginEnd + offsetX).toInt()
+                    val newY = (layoutParams.topMargin + (e2.y - (e1?.y ?: 0f))).toInt()
+                    if (newX >= 0 && newX <= width - view.width && newY >= 0 && newY <= height - view.height) {
+                        layoutParams.marginEnd = newX
+                        layoutParams.topMargin = newY
+                        view.layoutParams = layoutParams
+                    }
+                }
+                return true
+            }
+        })
+        view!!.setOnTouchListener { v, event -> detector.onTouchEvent(event) }
+    }
+
+    private val isRTL: Boolean
+        private get() {
+            val configuration = context.resources.configuration
+            val layoutDirection = configuration.layoutDirection
+            return layoutDirection == View.LAYOUT_DIRECTION_RTL
+        }
+}

+ 315 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/VideoView.kt

@@ -0,0 +1,315 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout
+
+import android.content.Context
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.utils.widget.ImageFilterView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine.Camera
+import com.tencent.cloud.tuikit.engine.common.TUIVideoView
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuicore.TUILogin
+import com.tencent.qcloud.tuicore.interfaces.ITUINotification
+import com.tencent.qcloud.tuicore.util.ScreenUtil
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.data.Constants
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.manager.EngineManager
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.common.CustomLoadingView
+import com.tencent.qcloud.tuikit.tuicallkit.view.root.BaseCallView
+import com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout.VideoViewModel
+import com.trtc.tuikit.common.livedata.Observer
+
+class VideoView(context: Context) : BaseCallView(context) {
+    private var tuiVideoView: TUIVideoView? = null
+    private var imageAvatar: ImageFilterView? = null
+    private var imageSwitchCamera: ImageView? = null
+    private var imageUserBlurBackground: ImageView? = null
+    private var imageNetworkBad: ImageView? = null
+    private var textUserName: TextView? = null
+    private var imageAudioInput: ImageView? = null
+    private var imageLoading: CustomLoadingView? = null
+    private var imageBackground: ImageView? = null
+    private var viewModel: VideoViewModel? = null
+
+    private var isShowFloatWindow: Boolean = false
+
+    private val notification = ITUINotification { key, subKey, param ->
+        if (key == Constants.EVENT_VIEW_STATE_CHANGED) {
+            isShowFloatWindow = subKey == Constants.EVENT_SHOW_FLOAT_VIEW
+        }
+    }
+    private var videoAvailableObserver = Observer<Boolean> {
+        if (it) {
+            tuiVideoView?.visibility = VISIBLE
+            imageBackground?.visibility = GONE
+            imageAvatar?.visibility = GONE
+            if (viewModel?.user?.id != viewModel?.selfUser?.id) {
+                EngineManager.instance.startRemoteView(viewModel?.user?.id, tuiVideoView, null)
+            }
+        } else {
+            tuiVideoView?.visibility = GONE
+            imageBackground?.visibility = VISIBLE
+            ImageLoader.loadBlurImage(context, imageBackground, viewModel?.user?.avatar?.get())
+
+            if (viewModel?.user?.id == viewModel?.selfUser?.id
+                && TUICallState.instance.scene.get() == TUICallDefine.Scene.SINGLE_CALL
+                && viewModel?.selfUser?.callStatus?.get() == TUICallDefine.Status.Waiting
+            ) {
+                imageAvatar?.visibility = GONE
+            } else {
+                imageAvatar?.visibility = VISIBLE
+                ImageLoader.loadImage(context, imageAvatar, viewModel?.user?.avatar?.get())
+            }
+        }
+
+        val show = it && viewModel?.user?.id == viewModel?.selfUser?.id
+                && viewModel?.showLargeViewUserId?.get() == viewModel?.selfUser?.id
+        refreshFunctionButton(show)
+    }
+
+    private var audioAvailableObserver = Observer<Boolean> {
+        if (!it && viewModel?.scene?.get() == TUICallDefine.Scene.GROUP_CALL && viewModel?.user == viewModel?.selfUser) {
+            imageAudioInput?.setImageResource(R.drawable.tuicallkit_ic_self_mute)
+            imageAudioInput?.visibility = VISIBLE
+        } else {
+            imageAudioInput?.visibility = GONE
+        }
+    }
+
+    private var playoutVolumeAvailableObserver = Observer<Int> {
+        if (isShowFloatWindow) {
+            imageAudioInput?.visibility = GONE
+            return@Observer
+        }
+        if (viewModel?.scene?.get() == TUICallDefine.Scene.GROUP_CALL
+            && viewModel?.user == viewModel?.selfUser && viewModel?.selfUser?.audioAvailable?.get() == false
+        ) {
+            imageAudioInput?.setImageResource(R.drawable.tuicallkit_ic_self_mute)
+            imageAudioInput?.visibility = VISIBLE
+        } else if (it > Constants.MIN_AUDIO_VOLUME && viewModel?.scene?.get() == TUICallDefine.Scene.GROUP_CALL) {
+            imageAudioInput?.setImageResource(R.drawable.tuicallkit_ic_audio_input)
+            imageAudioInput?.visibility = VISIBLE
+        } else {
+            imageAudioInput?.visibility = GONE
+        }
+    }
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        if (it == TUICallDefine.Status.Waiting && !viewModel?.user?.id.equals(TUILogin.getLoginUser())) {
+            imageLoading?.visibility = VISIBLE
+            imageLoading?.startLoading()
+        } else {
+            imageLoading?.visibility = GONE
+            imageLoading?.stopLoading()
+        }
+        if (viewModel?.user?.id == viewModel?.selfUser?.id && viewModel?.user?.videoAvailable?.get() == false) {
+            imageAvatar?.visibility = VISIBLE
+            ImageLoader.loadImage(context, imageAvatar, viewModel?.user?.avatar?.get())
+        }
+    }
+
+    private var avatarObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            ImageLoader.loadImage(context.applicationContext, imageAvatar, it, R.drawable.tuicallkit_ic_avatar)
+            ImageLoader.loadBlurImage(context, imageBackground, it)
+        }
+    }
+
+    private var nicknameObserver = Observer<String> {
+        if (!TextUtils.isEmpty(it)) {
+            textUserName?.text = it
+        }
+    }
+
+    private var showLargeViewUserIdObserver = Observer<String> {
+        val show = !it.isNullOrEmpty() && viewModel?.selfUser?.videoAvailable?.get() == true
+                && it == viewModel?.user?.id && it == viewModel?.selfUser?.id
+        refreshFunctionButton(show)
+        refreshUserNameView()
+    }
+
+    private var networkQualityObserver = Observer<Boolean> {
+        if (it && viewModel?.scene?.get() == TUICallDefine.Scene.GROUP_CALL) {
+            imageNetworkBad?.visibility = VISIBLE
+        } else {
+            imageNetworkBad?.visibility = GONE
+        }
+    }
+
+    init {
+        initView()
+    }
+
+    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
+        return super.onInterceptTouchEvent(ev)
+    }
+
+    override fun clear() {
+        removeObserver()
+    }
+
+    override fun onTouchEvent(event: MotionEvent?): Boolean {
+        return false
+    }
+
+    fun setUser(user: User) {
+        viewModel = VideoViewModel(user)
+        refreshView()
+
+        addObserver()
+    }
+
+    fun setImageAvatarVisibility(isShow: Boolean) {
+        if (isShow) {
+            imageAvatar?.visibility = VISIBLE
+            imageBackground?.visibility = VISIBLE
+            ImageLoader.loadImage(context, imageAvatar, viewModel?.user?.avatar?.get())
+            ImageLoader.loadBlurImage(context, imageBackground, viewModel?.user?.avatar?.get())
+        } else {
+            imageAvatar?.visibility = GONE
+            imageBackground?.visibility = GONE
+        }
+    }
+
+    fun setVideoIconVisibility(needShow: Boolean) {
+        if (!needShow) {
+            imageAudioInput?.visibility = GONE
+            textUserName?.visibility = GONE
+            refreshFunctionButton(false)
+        }
+    }
+
+    private fun addObserver() {
+        viewModel?.user?.videoAvailable?.observe(videoAvailableObserver)
+        viewModel?.user?.audioAvailable?.observe(audioAvailableObserver)
+        viewModel?.user?.playoutVolume?.observe(playoutVolumeAvailableObserver)
+        viewModel?.user?.callStatus?.observe(callStatusObserver)
+        viewModel?.user?.avatar?.observe(avatarObserver)
+        viewModel?.user?.nickname?.observe(nicknameObserver)
+        viewModel?.user?.networkQualityReminder?.observe(networkQualityObserver)
+
+        viewModel?.showLargeViewUserId?.observe(showLargeViewUserIdObserver)
+        TUICore.registerEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FULL_VIEW, notification)
+        TUICore.registerEvent(Constants.EVENT_VIEW_STATE_CHANGED, Constants.EVENT_SHOW_FLOAT_VIEW, notification)
+    }
+
+    private fun removeObserver() {
+        viewModel?.user?.videoAvailable?.removeObserver(videoAvailableObserver)
+        viewModel?.user?.audioAvailable?.removeObserver(audioAvailableObserver)
+        viewModel?.user?.playoutVolume?.removeObserver(playoutVolumeAvailableObserver)
+        viewModel?.user?.callStatus?.removeObserver(callStatusObserver)
+        viewModel?.user?.avatar?.removeObserver(avatarObserver)
+        viewModel?.user?.nickname?.removeObserver(nicknameObserver)
+        viewModel?.user?.networkQualityReminder?.removeObserver(networkQualityObserver)
+
+        viewModel?.showLargeViewUserId?.removeObserver(showLargeViewUserIdObserver)
+        TUICore.unRegisterEvent(notification)
+    }
+
+    private fun refreshView() {
+        if (TUICallDefine.Scene.GROUP_CALL == viewModel?.scene?.get()
+            && TUICallDefine.Status.Waiting == viewModel?.user?.callStatus?.get()
+        ) {
+            if (!viewModel?.user?.id.equals(TUILogin.getLoginUser())) {
+                imageLoading?.visibility = VISIBLE
+                imageLoading?.startLoading()
+            }
+        } else if (TUICallDefine.Status.Accept == viewModel?.user?.callStatus?.get()) {
+            if (viewModel?.user?.videoAvailable?.get() == true) {
+                tuiVideoView?.visibility = VISIBLE
+                imageAvatar?.visibility = GONE
+            } else {
+                tuiVideoView?.visibility = GONE
+                imageAvatar?.visibility = VISIBLE
+            }
+            imageLoading?.visibility = GONE
+            imageLoading?.stopLoading()
+        } else {
+            imageLoading?.visibility = GONE
+            imageLoading?.stopLoading()
+        }
+
+        refreshUserAvatarView()
+        refreshUserNameView()
+
+        val show = viewModel?.user?.videoAvailable?.get() == true
+                && viewModel?.showLargeViewUserId?.get() == viewModel?.user?.id
+        refreshFunctionButton(show)
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_video_view, this, true)
+        tuiVideoView = findViewById(R.id.tx_cloud_view)
+        imageAvatar = findViewById(R.id.img_head)
+        imageSwitchCamera = findViewById(R.id.iv_switch_camera)
+        imageUserBlurBackground = findViewById(R.id.iv_blur_background)
+        textUserName = findViewById(R.id.tv_name)
+        imageAudioInput = findViewById(R.id.iv_audio_input)
+        imageLoading = findViewById(R.id.img_loading)
+        imageBackground = findViewById(R.id.img_video_background)
+        imageNetworkBad = findViewById(R.id.iv_network)
+
+        refreshUserAvatarView()
+        refreshUserNameView()
+
+        imageSwitchCamera?.setOnClickListener() {
+            val camera = if (viewModel?.isFrontCamera?.get() == Camera.Front) Camera.Back else Camera.Front
+            EngineManager.instance.switchCamera(camera)
+        }
+        imageUserBlurBackground?.setOnClickListener {
+            EngineManager.instance.setBlurBackground(!TUICallState.instance.enableBlurBackground.get())
+            imageUserBlurBackground?.isActivated = TUICallState.instance.enableBlurBackground.get()
+        }
+    }
+
+    private fun refreshUserAvatarView() {
+        val layoutParams: LayoutParams = imageAvatar?.layoutParams as LayoutParams
+        if (TUICallDefine.Scene.GROUP_CALL == viewModel?.scene?.get()) {
+            layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
+            layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
+            layoutParams.removeRule(CENTER_IN_PARENT)
+            imageAvatar?.round = 0f
+        } else {
+            layoutParams.addRule(CENTER_IN_PARENT)
+            layoutParams.width = ScreenUtil.dip2px(80.0f)
+            layoutParams.height = ScreenUtil.dip2px(80.0f)
+            imageAvatar?.round = 12f
+        }
+        ImageLoader.loadImage(context, imageAvatar, viewModel?.user?.avatar?.get(), R.drawable.tuicallkit_ic_avatar)
+        imageAvatar?.layoutParams = layoutParams
+    }
+
+    private fun refreshUserNameView() {
+        if (TUICallDefine.Scene.GROUP_CALL == viewModel?.scene?.get()
+            && viewModel?.showLargeViewUserId?.get() == viewModel?.user?.id
+        ) {
+            textUserName?.visibility = VISIBLE
+        } else {
+            textUserName?.visibility = GONE
+        }
+
+        textUserName?.text = if (TextUtils.isEmpty(viewModel?.user?.nickname?.get())) {
+            viewModel?.user?.id
+        } else {
+            viewModel?.user?.nickname?.get()
+        }
+    }
+
+    private fun refreshFunctionButton(show: Boolean) {
+        imageSwitchCamera?.visibility = if (show) VISIBLE else GONE
+        imageUserBlurBackground?.visibility =
+            if (TUICallState.instance.showVirtualBackgroundButton && show) VISIBLE else GONE
+
+    }
+
+    fun getVideoView(): TUIVideoView? {
+        return tuiVideoView
+    }
+}

+ 65 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/component/videolayout/VideoViewFactory.kt

@@ -0,0 +1,65 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout
+
+import android.content.Context
+import android.text.TextUtils
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.utils.Logger
+
+class VideoViewFactory {
+    var videoEntityList = HashMap<String, UserVideoEntity?>()
+
+    companion object {
+        const val TAG = "VideoViewFactory"
+        var instance: VideoViewFactory = VideoViewFactory()
+    }
+
+    fun createVideoView(user: User?, context: Context): VideoView? {
+        if (null == user || TextUtils.isEmpty(user.id)) {
+            Logger.error(TAG, "createVideoView failed, user is invalid: $user")
+            return null
+        }
+        var videoView = findVideoView(user.id)
+        if (null != videoView) {
+            videoView.setUser(user)
+            return videoView
+        }
+        videoView = VideoView(context.applicationContext)
+        videoView.setUser(user)
+        var entity = UserVideoEntity(user?.id, videoView, user)
+        user?.id?.let { videoEntityList.put(user?.id!!, entity) }
+        return videoEntityList[user?.id]?.videoView
+    }
+
+    fun clear() {
+        if (videoEntityList.isNotEmpty()) {
+            for (userId in videoEntityList.keys) {
+                var entity = videoEntityList.get(userId)
+                entity?.videoView?.clear()
+            }
+            videoEntityList.clear()
+        }
+    }
+
+    fun findVideoView(userId: String?): VideoView? {
+        if (TextUtils.isEmpty(userId)) {
+            return null
+        }
+        if (videoEntityList.contains(userId)) {
+            return videoEntityList[userId]?.videoView
+        }
+        return null
+    }
+
+    class UserVideoEntity(id: String?, view: VideoView, user: User) {
+        var userId: String? = ""
+        var videoView: VideoView? = null
+        var user: User? = null
+
+        init {
+            this.userId = id
+            this.videoView = view
+            this.user = user
+        }
+    }
+
+}

+ 8 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/BaseCallView.kt

@@ -0,0 +1,8 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.root
+
+import android.content.Context
+import android.widget.RelativeLayout
+
+abstract class BaseCallView(context: Context) : RelativeLayout(context) {
+    abstract fun clear()
+}

+ 244 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/GroupCallView.kt

@@ -0,0 +1,244 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.root
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.utils.ImageLoader
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.CallTimerView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.CallWaitingHintView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.InviteUserButton
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatingWindowButton
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.AudioAndVideoCalleeWaitingView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.VideoCallerAndCalleeAcceptedView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.group.GroupCallerUserInfoView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.group.InviteeAvatarListView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.GroupCallVideoLayout
+import com.trtc.tuikit.common.livedata.Observer
+
+class GroupCallView(context: Context) : BaseCallView(context) {
+    private val context: Context = context
+    private var layoutRender: ScrollView? = null
+    private var layoutFunction: FrameLayout? = null
+    private var layoutCallTime: FrameLayout? = null
+    private var layoutInviterWaitHint: FrameLayout? = null
+    private var layoutFloatIcon: FrameLayout? = null
+    private var layoutInviteUserIcon: FrameLayout? = null
+    private var layoutCallerUserInfo: FrameLayout? = null
+    private var layoutInviteeWaitHint: FrameLayout? = null
+    private var layoutInviteeAvatar: LinearLayout? = null
+    private var imageBackground: ImageView? = null
+
+    private var groupCallVideoLayout: GroupCallVideoLayout? = null
+    private var functionWaitView: BaseCallView? = null
+    private var functionAcceptView: BaseCallView? = null
+    private var floatingWindowButton: FloatingWindowButton? = null
+    private var inviteUserButton: InviteUserButton? = null
+    private var callTimerView: CallTimerView? = null
+    private var callWaitingHintView: CallWaitingHintView? = null
+    private var callerUserInfo: GroupCallerUserInfoView? = null
+    private var inviteeAvatarListView: InviteeAvatarListView? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        refreshCallerUserInfoView()
+        refreshInviteeAvatarView()
+
+        refreshRenderView()
+        refreshFunctionView()
+        refreshFloatView()
+        refreshCallStatusView()
+        refreshTimerView()
+
+        showAntiFraudReminder()
+    }
+
+    private var bottomViewExpandObserver = Observer<Boolean> {
+        layoutFunction?.background = context.resources.getDrawable(R.drawable.tuicallkit_bg_group_call_bottom)
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    override fun clear() {
+        groupCallVideoLayout?.clear()
+        functionWaitView?.clear()
+        functionAcceptView?.clear()
+        floatingWindowButton?.clear()
+        inviteUserButton?.clear()
+        callTimerView?.clear()
+        callWaitingHintView?.clear()
+        callerUserInfo?.clear()
+        inviteeAvatarListView?.clear()
+        removeObserver()
+    }
+
+    fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_root_view_group, this)
+        layoutCallerUserInfo = findViewById(R.id.rl_layout_caller_user_info)
+        layoutInviteeWaitHint = findViewById(R.id.rl_layout_invitee_wait_hint)
+        layoutInviteeAvatar = findViewById(R.id.ll_layout_invitee_avatar)
+
+        layoutRender = findViewById(R.id.rl_layout_render)
+        layoutFunction = findViewById(R.id.rl_layout_function)
+        layoutFloatIcon = findViewById(R.id.rl_layout_float_icon)
+        layoutInviteUserIcon = findViewById(R.id.rl_layout_add_user)
+        layoutInviterWaitHint = findViewById(R.id.rl_layout_call_status)
+        layoutCallTime = findViewById(R.id.rl_layout_call_time)
+        imageBackground = findViewById(R.id.img_group_view_background)
+
+        ImageLoader.loadBlurImage(context, imageBackground, TUICallState.instance.selfUser.get().avatar.get())
+
+        refreshCallerUserInfoView()
+        refreshInviteeAvatarView()
+
+        refreshRenderView()
+        refreshFunctionView()
+        refreshFloatView()
+        refreshInviteUserIconView()
+        refreshCallStatusView()
+        refreshTimerView()
+    }
+
+    private fun refreshCallerUserInfoView() {
+        if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()
+            && TUICallDefine.Role.Called == TUICallState.instance.selfUser.get().callRole.get()
+        ) {
+            layoutCallerUserInfo?.visibility = VISIBLE
+            callerUserInfo = GroupCallerUserInfoView(context)
+            layoutCallerUserInfo!!.removeAllViews()
+            layoutCallerUserInfo!!.addView(callerUserInfo)
+        } else {
+            layoutCallerUserInfo?.visibility = GONE
+        }
+    }
+
+    private fun refreshInviteeAvatarView() {
+        if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()
+            && TUICallDefine.Role.Called == TUICallState.instance.selfUser.get().callRole.get()
+        ) {
+            layoutInviteeAvatar?.visibility = VISIBLE
+            inviteeAvatarListView = InviteeAvatarListView(context)
+            layoutInviteeAvatar!!.removeAllViews()
+            layoutInviteeAvatar!!.addView(inviteeAvatarListView)
+        } else {
+            layoutInviteeAvatar?.visibility = GONE
+        }
+    }
+
+    private fun refreshTimerView() {
+        if (TUICallDefine.Status.Accept == TUICallState.instance.selfUser.get().callStatus.get()) {
+            layoutCallTime?.removeAllViews()
+            callTimerView = CallTimerView(context)
+            layoutCallTime?.addView(callTimerView)
+        } else {
+            layoutCallTime?.removeAllViews()
+            callTimerView = null
+        }
+    }
+
+    private fun refreshCallStatusView() {
+        if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()) {
+            if (TUICallDefine.Role.Called == TUICallState.instance.selfUser.get().callRole.get()) {
+                layoutInviteeWaitHint?.visibility = VISIBLE
+                layoutInviterWaitHint?.visibility = GONE
+                layoutInviteeWaitHint?.removeAllViews()
+                callWaitingHintView = CallWaitingHintView(context)
+                layoutInviteeWaitHint?.addView(callWaitingHintView)
+            } else {
+                layoutInviteeWaitHint?.visibility = GONE
+                layoutInviterWaitHint?.visibility = VISIBLE
+                layoutInviterWaitHint?.removeAllViews()
+                callWaitingHintView = CallWaitingHintView(context)
+                layoutInviterWaitHint?.addView(callWaitingHintView)
+            }
+        } else {
+            layoutInviterWaitHint?.removeAllViews()
+            callWaitingHintView = null
+        }
+    }
+
+    private fun refreshInviteUserIconView() {
+        inviteUserButton = InviteUserButton(context)
+        layoutInviteUserIcon?.addView(inviteUserButton)
+    }
+
+    private fun refreshFloatView() {
+        layoutFloatIcon?.removeAllViews()
+        floatingWindowButton = FloatingWindowButton(context)
+        layoutFloatIcon?.addView(floatingWindowButton)
+    }
+
+    private fun refreshFunctionView() {
+        if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Called) {
+            if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()) {
+                functionWaitView = AudioAndVideoCalleeWaitingView(context)
+                layoutFunction!!.removeAllViews()
+                layoutFunction!!.addView(functionWaitView)
+            } else {
+                if (functionAcceptView == null) {
+                    functionAcceptView = VideoCallerAndCalleeAcceptedView(context)
+                }
+                layoutFunction!!.removeAllViews()
+                layoutFunction!!.addView(functionAcceptView)
+            }
+        } else if (functionAcceptView == null) {
+            functionAcceptView = VideoCallerAndCalleeAcceptedView(context)
+            layoutFunction!!.removeAllViews()
+            layoutFunction!!.addView(functionAcceptView)
+        }
+    }
+
+    private fun refreshRenderView() {
+        if (TUICallDefine.Role.Called == TUICallState.instance.selfUser.get().callRole.get()) {
+            if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()) {
+                layoutRender?.visibility = GONE
+            } else {
+                layoutRender?.visibility = VISIBLE
+                if (groupCallVideoLayout == null) {
+                    groupCallVideoLayout = GroupCallVideoLayout(context)
+                    layoutRender!!.removeAllViews()
+                    layoutRender!!.addView(groupCallVideoLayout)
+                }
+            }
+        } else if (groupCallVideoLayout == null) {
+            layoutRender?.visibility = VISIBLE
+            groupCallVideoLayout = GroupCallVideoLayout(context)
+            layoutRender!!.removeAllViews()
+            layoutRender!!.addView(groupCallVideoLayout)
+        }
+    }
+
+    private fun showAntiFraudReminder() {
+        if (TUICallDefine.Status.Accept != TUICallState.instance.selfUser.get().callStatus.get()) {
+            return
+        }
+
+        if (TUICore.getService(TUIConstants.Service.TUI_PRIVACY) == null) {
+            return
+        }
+        var map = HashMap<String, Any?>()
+        map[TUIConstants.Privacy.PARAM_DIALOG_CONTEXT] = context
+        TUICore.callService(
+            TUIConstants.Service.TUI_PRIVACY, TUIConstants.Privacy.METHOD_ANTO_FRAUD_REMINDER, map, null
+        )
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICallState.instance.isBottomViewExpand.observe(bottomViewExpandObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICallState.instance.isBottomViewExpand.removeObserver(bottomViewExpandObserver)
+    }
+}

+ 211 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/view/root/SingleCallView.kt

@@ -0,0 +1,211 @@
+package com.tencent.qcloud.tuikit.tuicallkit.view.root
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.RelativeLayout
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuicore.TUIConstants
+import com.tencent.qcloud.tuicore.TUICore
+import com.tencent.qcloud.tuikit.tuicallkit.R
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.CallTimerView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.CallWaitingHintView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatingWindowButton
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.AudioAndVideoCalleeWaitingView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.AudioCallerWaitingAndAcceptedView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.VideoCallerAndCalleeAcceptedView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.function.VideoCallerWaitingView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.single.AudioCallUserInfoView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.userinfo.single.VideoCallUserInfoView
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.videolayout.SingleCallVideoLayout
+import com.trtc.tuikit.common.livedata.Observer
+
+class SingleCallView(context: Context) : RelativeLayout(context) {
+    private val context: Context = context
+    private var layoutTimer: FrameLayout? = null
+    private var layoutUserInfo: FrameLayout? = null
+    private var layoutFunction: FrameLayout? = null
+    private var layoutFloatIcon: FrameLayout? = null
+    private var layoutRender: FrameLayout? = null
+    private var layoutCallTag: FrameLayout? = null
+
+    private var functionView: BaseCallView? = null
+    private var userInfoView: BaseCallView? = null
+    private var callTimerView: CallTimerView? = null
+    private var hintView: CallWaitingHintView? = null
+    private var singleCallVideoLayout: SingleCallVideoLayout? = null
+    private var floatingWindowButton: FloatingWindowButton? = null
+
+    private var callStatusObserver = Observer<TUICallDefine.Status> {
+        refreshFunctionView()
+        refreshTimerView()
+        showAntiFraudReminder()
+    }
+
+    private var mediaTypeObserver = Observer<TUICallDefine.MediaType> {
+        if (it != TUICallDefine.MediaType.Unknown) {
+            refreshUserInfoView()
+            refreshFunctionView()
+            refreshRenderView()
+            refreshFloatView()
+        }
+    }
+
+    private var isShowFullScreenObserver = Observer<Boolean> {
+        if (it) {
+            layoutFloatIcon?.visibility = GONE
+            layoutTimer?.visibility = GONE
+            layoutFunction?.visibility = GONE
+        } else {
+            layoutFloatIcon?.visibility = VISIBLE
+            layoutTimer?.visibility = VISIBLE
+            layoutFunction?.visibility = VISIBLE
+            layoutFloatIcon?.bringToFront()
+            layoutTimer?.bringToFront()
+            layoutFunction?.bringToFront()
+        }
+    }
+
+    init {
+        initView()
+        addObserver()
+    }
+
+    fun clear() {
+        functionView?.clear()
+        userInfoView?.clear()
+        callTimerView?.clear()
+        singleCallVideoLayout?.clear()
+        floatingWindowButton?.clear()
+        removeObserver()
+    }
+
+    private fun initView() {
+        LayoutInflater.from(context).inflate(R.layout.tuicallkit_root_view_single, this)
+        layoutRender = findViewById(R.id.rl_container)
+        layoutFloatIcon = findViewById(R.id.rl_layout_float_icon)
+        layoutUserInfo = findViewById(R.id.rl_user_info_layout)
+        layoutTimer = findViewById(R.id.rl_single_time)
+        layoutFunction = findViewById(R.id.rl_single_function)
+        layoutCallTag = findViewById(R.id.fl_call_tag)
+
+        hintView = CallWaitingHintView(context)
+        if (hintView?.parent != null) {
+            (hintView?.parent as ViewGroup).removeView(hintView)
+        }
+        if (hintView != null) {
+            layoutCallTag?.addView(hintView)
+        }
+
+        refreshUserInfoView()
+        refreshFunctionView()
+        refreshTimerView()
+        refreshRenderView()
+        refreshFloatView()
+    }
+
+    private fun refreshFloatView() {
+        layoutFloatIcon?.removeAllViews()
+        floatingWindowButton = FloatingWindowButton(context)
+        layoutFloatIcon?.addView(floatingWindowButton)
+    }
+
+    private fun refreshRenderView() {
+        if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Video) {
+            singleCallVideoLayout = SingleCallVideoLayout(context)
+            layoutRender!!.removeAllViews()
+            layoutRender!!.addView(singleCallVideoLayout)
+        } else {
+            layoutRender!!.removeAllViews()
+            singleCallVideoLayout = null
+        }
+    }
+
+    private fun refreshTimerView() {
+        if (TUICallDefine.Status.Accept == TUICallState.instance.selfUser.get().callStatus.get()) {
+            layoutTimer?.removeAllViews()
+            callTimerView = CallTimerView(context)
+            layoutTimer?.addView(callTimerView)
+        } else {
+            layoutTimer?.removeAllViews()
+            callTimerView = null
+        }
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.selfUser.get().callStatus.observe(callStatusObserver)
+        TUICallState.instance.mediaType.observe(mediaTypeObserver)
+        TUICallState.instance.isShowFullScreen.observe(isShowFullScreenObserver)
+    }
+
+    private fun removeObserver() {
+        TUICallState.instance.selfUser.get().callStatus.removeObserver(callStatusObserver)
+        TUICallState.instance.mediaType.removeObserver(mediaTypeObserver)
+        TUICallState.instance.isShowFullScreen.removeObserver(isShowFullScreenObserver)
+    }
+
+    private fun refreshUserInfoView() {
+        if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Audio) {
+            layoutUserInfo?.visibility = VISIBLE
+            userInfoView = AudioCallUserInfoView(context)
+            layoutUserInfo!!.removeAllViews()
+            if (null != userInfoView) {
+                layoutUserInfo!!.addView(userInfoView)
+            }
+        } else {
+            if (TUICallState.instance.selfUser.get().callStatus.get() == TUICallDefine.Status.Waiting) {
+                layoutUserInfo?.visibility = VISIBLE
+                userInfoView = VideoCallUserInfoView(context)
+                layoutUserInfo!!.removeAllViews()
+                if (null != userInfoView) {
+                    layoutUserInfo!!.addView(userInfoView)
+                }
+            } else {
+                layoutUserInfo?.visibility = GONE
+            }
+        }
+    }
+
+    private fun refreshFunctionView() {
+        if (TUICallDefine.Status.Waiting == TUICallState.instance.selfUser.get().callStatus.get()) {
+
+            if (TUICallState.instance.selfUser.get().callRole.get() == TUICallDefine.Role.Caller) {
+                if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Audio) {
+                    functionView = AudioCallerWaitingAndAcceptedView(context)
+                } else {
+                    functionView = VideoCallerWaitingView(context)
+                }
+            } else {
+                functionView = AudioAndVideoCalleeWaitingView(context)
+            }
+        } else if (TUICallDefine.Status.Accept == TUICallState.instance.selfUser.get().callStatus.get()) {
+            if (TUICallState.instance.mediaType.get() == TUICallDefine.MediaType.Audio) {
+                functionView = AudioCallerWaitingAndAcceptedView(context)
+            } else {
+                functionView = VideoCallerAndCalleeAcceptedView(context)
+            }
+        }
+
+        layoutFunction!!.removeAllViews()
+        if (null != functionView) {
+            layoutFunction!!.addView(functionView)
+        }
+    }
+
+    private fun showAntiFraudReminder() {
+        if (TUICallDefine.Status.Accept != TUICallState.instance.selfUser.get().callStatus.get()) {
+            return
+        }
+
+        if (TUICore.getService(TUIConstants.Service.TUI_PRIVACY) == null) {
+            return
+        }
+        var map = HashMap<String, Any?>()
+        map[TUIConstants.Privacy.PARAM_DIALOG_CONTEXT] = context
+        TUICore.callService(
+            TUIConstants.Service.TUI_PRIVACY, TUIConstants.Privacy.METHOD_ANTO_FRAUD_REMINDER, map, null
+        )
+    }
+}

+ 33 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/floatview/FloatingWindowViewModel.kt

@@ -0,0 +1,33 @@
+package com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.floatview
+
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.tencent.qcloud.tuikit.tuicallkit.view.component.floatview.FloatWindowService
+import com.trtc.tuikit.common.livedata.LiveData
+
+class FloatingWindowViewModel {
+    public var selfUser: User
+    public var remoteUser: User
+    public var remoteUserList: LinkedHashSet<User>
+    public var timeCount = LiveData<Int>()
+    public var scene = LiveData<TUICallDefine.Scene>()
+    public var mediaType = LiveData<TUICallDefine.MediaType>()
+
+    init {
+        this.scene = TUICallState.instance.scene
+        this.selfUser = TUICallState.instance.selfUser.get()
+        this.mediaType = TUICallState.instance.mediaType
+        remoteUserList = TUICallState.instance.remoteUserList.get()
+        if (remoteUserList != null && remoteUserList.size > 0) {
+            remoteUser = remoteUserList.first()
+        } else {
+            remoteUser = User()
+        }
+        this.timeCount = TUICallState.instance.timeCount
+    }
+
+    fun stopFloatService() {
+        FloatWindowService.stopService()
+    }
+}

+ 70 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/GroupCallVideoLayoutViewModel.kt

@@ -0,0 +1,70 @@
+package com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout
+
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.LiveData
+import com.trtc.tuikit.common.livedata.Observer
+import java.util.concurrent.CopyOnWriteArrayList
+
+class GroupCallVideoLayoutViewModel {
+    public var userList = LiveData<CopyOnWriteArrayList<User>>()
+    public var isCameraOpen = LiveData<Boolean>()
+    public var isFrontCamera = LiveData<TUICommonDefine.Camera>()
+    public var mediaType = LiveData<TUICallDefine.MediaType>()
+    public var changedUser = LiveData<User>()
+    public var showLargeViewUserId = LiveData<String>()
+
+    private var remoteUserListObserver = Observer<LinkedHashSet<User>> {
+        if (it != null && it.size > 0) {
+            for (user in it) {
+                if (!userList.get().contains(user)) {
+                    userList.get().add(user)
+                    changedUser.set(user)
+                }
+            }
+            for ((index, user) in userList.get().withIndex()) {
+                if (index == 0) {
+                    continue
+                }
+                if (!it.contains(user)) {
+                    user.callStatus.set(TUICallDefine.Status.None)
+                    userList.get().remove(user)
+                    changedUser.set(user)
+                }
+            }
+        }
+    }
+
+    init {
+        isCameraOpen = TUICallState.instance.isCameraOpen
+        isFrontCamera = TUICallState.instance.isFrontCamera
+        mediaType = TUICallState.instance.mediaType
+        changedUser.set(User())
+        userList.set(CopyOnWriteArrayList())
+        userList.get().add(TUICallState.instance.selfUser.get())
+        userList.get().addAll(TUICallState.instance.remoteUserList.get())
+        showLargeViewUserId = TUICallState.instance.showLargeViewUserId
+
+        addObserver()
+    }
+
+    private fun addObserver() {
+        TUICallState.instance.remoteUserList.observe(remoteUserListObserver)
+    }
+
+    public fun removeObserver() {
+        TUICallState.instance.remoteUserList.removeObserver(remoteUserListObserver)
+    }
+
+    fun updateShowLargeViewUserId(userId: String?) {
+        TUICallState.instance.showLargeViewUserId.set(userId)
+    }
+
+    fun updateBottomViewExpanded(isExpand: Boolean) {
+        if (isExpand != TUICallState.instance.isBottomViewExpand.get()) {
+            TUICallState.instance.isBottomViewExpand.set(!TUICallState.instance.isBottomViewExpand.get())
+        }
+    }
+}

+ 45 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/SingleCallVideoLayoutViewModel.kt

@@ -0,0 +1,45 @@
+package com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout
+
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.LiveData
+
+class SingleCallVideoLayoutViewModel {
+    public var selfUser: User
+    public var remoteUser: User
+    public var isCameraOpen = LiveData<Boolean>()
+    public var isFrontCamera = LiveData<TUICommonDefine.Camera>()
+    public var currentReverseRenderView = false
+    public var lastReverseRenderView = false
+    public var isShowFullScreen = false
+    public var enableBlurBackground = LiveData<Boolean>()
+
+    init {
+        selfUser = TUICallState.instance.selfUser.get()
+        val remoteUserList = TUICallState.instance.remoteUserList.get()
+        remoteUser = if (remoteUserList != null && remoteUserList.size > 0) {
+            remoteUserList.first()
+        } else {
+            User()
+        }
+        isCameraOpen = TUICallState.instance.isCameraOpen
+        isFrontCamera = TUICallState.instance.isFrontCamera
+        lastReverseRenderView = TUICallState.instance.reverse1v1CallRenderView
+        enableBlurBackground = TUICallState.instance.enableBlurBackground
+    }
+
+    public fun reverseRenderLayout(reverse: Boolean) {
+        currentReverseRenderView = reverse
+        TUICallState.instance.reverse1v1CallRenderView = reverse
+    }
+
+    public fun showFullScreen() {
+        if (TUICallState.instance.selfUser.get().callStatus.get() != TUICallDefine.Status.Accept) {
+            return
+        }
+        isShowFullScreen = !isShowFullScreen
+        TUICallState.instance.isShowFullScreen.set(isShowFullScreen)
+    }
+}

+ 23 - 0
frame/tuicallkit/src/main/java/com/tencent/qcloud/tuikit/tuicallkit/viewmodel/component/videolayout/VideoViewModel.kt

@@ -0,0 +1,23 @@
+package com.tencent.qcloud.tuikit.tuicallkit.viewmodel.component.videolayout
+
+import com.tencent.cloud.tuikit.engine.call.TUICallDefine
+import com.tencent.cloud.tuikit.engine.common.TUICommonDefine
+import com.tencent.qcloud.tuikit.tuicallkit.data.User
+import com.tencent.qcloud.tuikit.tuicallkit.state.TUICallState
+import com.trtc.tuikit.common.livedata.LiveData
+
+class VideoViewModel(user: User) {
+    var user: User? = null
+    var selfUser: User
+    var scene: LiveData<TUICallDefine.Scene>? = null
+    var isFrontCamera: LiveData<TUICommonDefine.Camera>
+    var showLargeViewUserId: LiveData<String>
+
+    init {
+        this.user = user
+        this.scene = TUICallState.instance.scene
+        this.isFrontCamera = TUICallState.instance.isFrontCamera
+        selfUser = TUICallState.instance.selfUser.get()
+        showLargeViewUserId = TUICallState.instance.showLargeViewUserId
+    }
+}

BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_chat_title_bar_minimalist_audio_call_icon.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_chat_title_bar_minimalist_video_call_icon.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_check_box_group_selected.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_check_box_group_unselected.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_checkbox_selected.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_group_select_disable.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_add_user_black.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_audio_call.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_audio_input.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_avatar.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_back.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_accept.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_waiting_disable.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_blur_background_waiting_enable.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_camera_disable.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_camera_enable.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_delete.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing_pressed.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_dialing_video.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_edit.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_audio_off.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_audio_on.png


BIN
frame/tuicallkit/src/main/res/drawable-xxhdpi/tuicallkit_ic_float_video_off.png


Некоторые файлы не были показаны из-за большого количества измененных файлов