liuyuqi-dellpc 2 years ago
commit
3c90f6e479
76 changed files with 4049 additions and 0 deletions
  1. 9 0
      .gitignore
  2. 0 0
      .gitkeep
  3. 43 0
      README.md
  4. 1 0
      app/.gitignore
  5. 70 0
      app/build.gradle
  6. 21 0
      app/proguard-rules.pro
  7. 140 0
      app/src/main/AndroidManifest.xml
  8. 36 0
      app/src/main/java/com/netease/yunxin/app/videocall/DemoApplication.java
  9. 162 0
      app/src/main/java/com/netease/yunxin/app/videocall/MainActivity.java
  10. 33 0
      app/src/main/java/com/netease/yunxin/app/videocall/SelfNotificationConfigFetcher.java
  11. 52 0
      app/src/main/java/com/netease/yunxin/app/videocall/SelfUserInfoHelper.java
  12. 37 0
      app/src/main/java/com/netease/yunxin/app/videocall/SplashActivity.java
  13. 79 0
      app/src/main/java/com/netease/yunxin/app/videocall/base/BaseService.java
  14. 56 0
      app/src/main/java/com/netease/yunxin/app/videocall/base/CommonDataManager.java
  15. 283 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/model/LoginServiceManager.java
  16. 151 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/model/ProfileManager.java
  17. 26 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/model/UserModel.java
  18. 95 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/ui/LoginActivity.java
  19. 158 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/ui/VerifyCodeActivity.java
  20. 392 0
      app/src/main/java/com/netease/yunxin/app/videocall/login/ui/view/VerifyCodeView.java
  21. 147 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/CallOrderManager.java
  22. 86 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/CallServiceManager.java
  23. 97 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/UserCacheManager.java
  24. 26 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/model/CallOrder.java
  25. 192 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/NERTCSelectCallUserActivity.java
  26. 164 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/adapter/CallOrderAdapter.java
  27. 120 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/adapter/RecentUserAdapter.java
  28. 35 0
      app/src/main/java/com/netease/yunxin/app/videocall/nertc/utils/SelfTimeUtils.java
  29. 30 0
      app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  30. BIN
      app/src/main/res/drawable-xxhdpi/account_circle.png
  31. BIN
      app/src/main/res/drawable-xxhdpi/audio_in_failed.png
  32. BIN
      app/src/main/res/drawable-xxhdpi/audio_in_normal.png
  33. BIN
      app/src/main/res/drawable-xxhdpi/audio_out_failed.png
  34. BIN
      app/src/main/res/drawable-xxhdpi/audio_out_normal.png
  35. BIN
      app/src/main/res/drawable-xxhdpi/main_bg.png
  36. BIN
      app/src/main/res/drawable-xxhdpi/nim_icon_edit_delete.png
  37. BIN
      app/src/main/res/drawable-xxhdpi/seting.png
  38. BIN
      app/src/main/res/drawable-xxhdpi/video_call_icon.png
  39. BIN
      app/src/main/res/drawable-xxhdpi/video_in_failed.png
  40. BIN
      app/src/main/res/drawable-xxhdpi/video_in_normal.png
  41. BIN
      app/src/main/res/drawable-xxhdpi/video_out_failed.png
  42. BIN
      app/src/main/res/drawable-xxhdpi/video_out_normal.png
  43. BIN
      app/src/main/res/drawable-xxhdpi/yunxin_logo.png
  44. 19 0
      app/src/main/res/drawable/btn_call_bg.xml
  45. 8 0
      app/src/main/res/drawable/btn_search_bg.xml
  46. 6 0
      app/src/main/res/drawable/et_cursor.xml
  47. 19 0
      app/src/main/res/drawable/et_login_code.xml
  48. 8 0
      app/src/main/res/drawable/et_search_bg.xml
  49. 170 0
      app/src/main/res/drawable/ic_launcher_background.xml
  50. 8 0
      app/src/main/res/drawable/item_call_bg.xml
  51. 39 0
      app/src/main/res/drawable/light_right_arrow.xml
  52. 18 0
      app/src/main/res/drawable/login_button_border.xml
  53. 5 0
      app/src/main/res/drawable/login_button_text_selector.xml
  54. 7 0
      app/src/main/res/drawable/video_call_bg.xml
  55. 107 0
      app/src/main/res/layout/activity_main.xml
  56. 41 0
      app/src/main/res/layout/call_order_item_layout.xml
  57. 102 0
      app/src/main/res/layout/login_activity.xml
  58. 30 0
      app/src/main/res/layout/user_item_layout.xml
  59. 86 0
      app/src/main/res/layout/verify_code_layout.xml
  60. 160 0
      app/src/main/res/layout/video_call_select_layout.xml
  61. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher.png
  62. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  63. 30 0
      app/src/main/res/values/attrs.xml
  64. 11 0
      app/src/main/res/values/colors.xml
  65. 34 0
      app/src/main/res/values/strings.xml
  66. 10 0
      app/src/main/res/values/styles.xml
  67. 4 0
      app/src/main/res/xml/network_security_config.xml
  68. 61 0
      build.gradle
  69. 23 0
      gradle.properties
  70. BIN
      gradle/wrapper/gradle-wrapper.jar
  71. 6 0
      gradle/wrapper/gradle-wrapper.properties
  72. 172 0
      gradlew
  73. 84 0
      gradlew.bat
  74. 2 0
      settings.gradle
  75. 38 0
      workflow/config_stub.py
  76. BIN
      workflow/release.jks.gpg

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx

+ 0 - 0
.gitkeep


+ 43 - 0
README.md

@@ -0,0 +1,43 @@
+## 云信一对一视频通话(Android)
+
+本文主要展示如何集成云信的NIM SDK以及NERTC SDK,快速实现一对一视频通话功能。您可以直接基于我们的Demo修改适配,也可以使用我们提供的NERtcVideoCall组件,实现自定义UI。
+
+### <span id="功能介绍">功能介绍</span>
+
+- 视频呼叫/接听
+- 开启/关闭麦克风
+- 开启/关闭摄像头
+- 切换前后摄像头
+- 视频拒绝/挂断
+
+### <span id="环境准备">环境准备</span>
+
+1. 登录[网易云控制台](https://app.yunxin.163.com/index?clueFrom=nim&from=nim#/),点击【应用】>【创建】创建自己的App,在【功能管理】中申请开通【信令】和【音视频通话2.0】功能。
+
+2. 在控制台中【appkey管理】获取appkey。
+
+3. 下载[场景Demo](https://github.com/netease-im/NEVideoCall-1to1/tree/develop/NLiteAVDemo-Android-Java),app module 下 build.gradle 的如下内容替换自己的appkey,并将 key 同步给对应的 so 人员在后台添加应用体验权限;
+
+   ```groovy
+   def appKey = "Here, please fill your appKey!!!"
+   ```
+
+   
+
+### <span id="运行示例项目">运行示例项目</span>
+
+
+### <span id="功能实现">功能实现</span>
+可参考[NERtcCallKit-Android](https://github.com/netease-kit/documents/tree/main/业务组件/呼叫组件)。
+
+NERtcVideoCall组件:
+
+   ![](https://yx-web-nosdn.netease.im/quickhtml%2Fassets%2Fyunxin%2Fdefault%2FIOS%E5%9C%BA%E6%99%AF%E5%AE%9E%E8%B7%B5%20%E9%99%84%E5%9B%BE.png)
+
+#### 修改Demo源代码:
+
+Demo跑通之后,可以修改工程文件夹下的Activity,复用**联系人搜索页**。
+
+| 文件/文件夹                 | 功能             |
+| :-------------------------- | :--------------- |
+| NERTCSelectCallUserActivity | **联系人搜索页** |

+ 1 - 0
app/.gitignore

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

+ 70 - 0
app/build.gradle

@@ -0,0 +1,70 @@
+apply plugin: 'com.android.application'
+
+android {
+    compileSdkVersion rootProject.ext.compileSdkVersion
+    buildToolsVersion rootProject.ext.buildToolsVersion
+
+    defaultConfig {
+        applicationId "com.netease.yunxin.app.videocall"
+
+        minSdkVersion rootProject.ext.minSdkVersion
+        targetSdkVersion rootProject.ext.targetSdkVersion
+        versionCode Integer.parseInt(VERSION_CODE)
+        versionName VERSION_NAME
+
+        ndk {
+            rootProject.ext.ndkAbis.each { abi ->
+                abiFilter(abi)
+            }
+        }
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+    packagingOptions {
+        pickFirst 'lib/arm64-v8a/libc++_shared.so'
+        pickFirst 'lib/armeabi-v7a/libc++_shared.so'
+    }
+
+    def appKey = "Here, please fill your appKey!!!"
+
+    // app key for code
+    defaultConfig {
+        buildConfigField "String", "APP_KEY", "\"${appKey}\""
+    }
+    // base server url
+    defaultConfig {
+        buildConfigField "String", "BASE_URL", "\"https://yiyong.netease.im/\""
+    }
+
+    configurations.all {
+        // check for updates every build
+        resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
+    }
+}
+
+dependencies {
+    implementation fileTree(dir: "libs", include: ["*.jar"])
+    implementation 'androidx.appcompat:appcompat:1.2.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
+    implementation 'androidx.recyclerview:recyclerview:1.1.0'
+
+    api 'com.squareup.okhttp3:okhttp:3.11.0'
+    api 'com.squareup.okhttp3:logging-interceptor:3.11.0'
+    api 'com.squareup.retrofit2:retrofit:2.2.0'
+    api 'com.squareup.retrofit2:converter-gson:2.2.0'
+    implementation 'com.blankj:utilcodex:1.30.5'
+
+    implementation 'com.github.bumptech.glide:glide:4.13.1'
+    api 'com.netease.yunxin.kit.call:call-ui:1.8.1'
+}

+ 21 - 0
app/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

+ 140 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.netease.yunxin.app.videocall">
+
+    <!-- 访问网络状态-->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+
+    <!-- 外置存储存取权限 -->
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+
+    <!-- 多媒体相关 -->
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+
+    <!-- 控制呼吸灯,振动器等,用于新消息提醒 -->
+    <uses-permission android:name="android.permission.FLASHLIGHT" />
+    <uses-permission android:name="android.permission.VIBRATE" />
+
+    <!--    蓝牙-->
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+
+    <uses-feature android:name="android.hardware.camera" />
+    <uses-feature android:name="android.hardware.camera.autofocus" />
+
+    <!-- 8.0+系统需要-->
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+
+    <!-- 下面的 uses-permission 一起加入到你的 AndroidManifest 文件中。 -->
+    <permission
+        android:name="com.netease.yunxin.nertc.demo.permission.RECEIVE_MSG"
+        android:protectionLevel="signature" />
+
+    <uses-permission android:name="com.netease.yunxin.nertc.demo.permission.RECEIVE_MSG" />
+
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
+    <application
+        android:name="com.netease.yunxin.app.videocall.DemoApplication"
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher"
+        android:supportsRtl="true"
+        android:networkSecurityConfig="@xml/network_security_config"
+        android:theme="@style/AppTheme">
+
+        <activity
+            android:name="com.netease.yunxin.app.videocall.SplashActivity"
+            android:theme="@style/Theme.AppCompat.NoActionBar"
+            android:screenOrientation="portrait"
+            android:launchMode="singleTop">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name="com.netease.yunxin.app.videocall.MainActivity"
+            android:theme="@style/Theme.AppCompat.NoActionBar"
+            android:screenOrientation="portrait"
+            android:launchMode="singleTask">
+
+            <intent-filter>
+                <action android:name="com.nertc.g2.action.main" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+
+        </activity>
+
+        <activity
+            android:name="com.netease.yunxin.app.videocall.login.ui.LoginActivity"
+            android:theme="@style/Theme.AppCompat.NoActionBar"
+            android:screenOrientation="portrait"
+            android:launchMode="singleTop"/>
+
+        <activity
+            android:name="com.netease.yunxin.app.videocall.login.ui.VerifyCodeActivity"
+            android:theme="@style/Theme.AppCompat.NoActionBar"
+            android:screenOrientation="portrait"
+            android:launchMode="singleTop"/>
+
+        <activity
+            android:name="com.netease.yunxin.app.videocall.nertc.ui.NERTCSelectCallUserActivity"
+            android:launchMode="singleTop"
+            android:screenOrientation="portrait"
+            android:theme="@style/Theme.AppCompat.NoActionBar"/>
+
+        <!-- 云信后台服务,请使用独立进程。 -->
+        <service
+            android:name="com.netease.nimlib.service.NimService"
+            android:process=":core" />
+
+        <!-- 云信后台辅助服务 -->
+        <service
+            android:name="com.netease.nimlib.service.NimService$Aux"
+            android:process=":core" />
+
+        <!-- 云信后台辅助服务 -->
+        <service
+            android:name="com.netease.nimlib.job.NIMJobService"
+            android:exported="false"
+            android:permission="android.permission.BIND_JOB_SERVICE"
+            android:process=":core" />
+
+        <!-- 云信监视系统启动和网络变化的广播接收器,保持和 NimService 同一进程 -->
+        <receiver
+            android:name="com.netease.nimlib.service.NimReceiver"
+            android:process=":core"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+                <action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
+            </intent-filter>
+        </receiver>
+
+        <!-- 云信进程间通信 Receiver -->
+        <receiver android:name="com.netease.nimlib.service.ResponseReceiver" />
+
+        <!-- 云信进程间通信service -->
+        <service android:name="com.netease.nimlib.service.ResponseService" />
+
+        <!-- 云信进程间通信provider -->
+        <!-- android:authorities="{包名}.ipc.provider", 请将com.netease.nim.demo替换为自己的包名 -->
+        <provider
+            android:name="com.netease.nimlib.ipc.NIMContentProvider"
+            android:authorities="com.netease.videocall.demo.ipc.provider"
+            android:exported="false"
+            android:process=":core" />
+    </application>
+
+</manifest>

+ 36 - 0
app/src/main/java/com/netease/yunxin/app/videocall/DemoApplication.java

@@ -0,0 +1,36 @@
+package com.netease.yunxin.app.videocall;
+
+import android.app.Application;
+import android.text.TextUtils;
+
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.SDKOptions;
+import com.netease.nimlib.sdk.auth.LoginInfo;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+
+
+public class DemoApplication extends Application {
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        NIMClient.init(this, loginInfo(), options());
+    }
+
+    // 如果返回值为 null,则全部使用默认参数。
+    private SDKOptions options() {
+        SDKOptions options = new SDKOptions();
+        //此处仅设置appkey,其他设置请自行参看信令文档设置 :https://dev.yunxin.163.com/docs/product/信令/SDK开发集成/Android开发集成/初始化
+        options.appKey = BuildConfig.APP_KEY;
+        return options;
+    }
+
+    // 如果已经存在用户登录信息,返回LoginInfo,否则返回null即可
+    private LoginInfo loginInfo() {
+        UserModel userModel = ProfileManager.getInstance().getUserModel();
+        if (userModel != null && !TextUtils.isEmpty(userModel.imToken) && !TextUtils.isEmpty(userModel.imAccid)) {
+            return new LoginInfo(String.valueOf(userModel.imAccid), userModel.imToken);
+        }
+        return null;
+    }
+}

+ 162 - 0
app/src/main/java/com/netease/yunxin/app/videocall/MainActivity.java

@@ -0,0 +1,162 @@
+package com.netease.yunxin.app.videocall;
+
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.blankj.utilcode.util.ToastUtils;
+import com.netease.lava.nertc.sdk.NERtc;
+import com.netease.lava.nertc.sdk.NERtcEx;
+import com.netease.lava.nertc.sdk.NERtcOption;
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.Observer;
+import com.netease.nimlib.sdk.StatusCode;
+import com.netease.nimlib.sdk.auth.AuthServiceObserver;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.ui.LoginActivity;
+import com.netease.yunxin.app.videocall.nertc.biz.CallOrderManager;
+import com.netease.yunxin.app.videocall.nertc.ui.NERTCSelectCallUserActivity;
+import com.netease.yunxin.nertc.ui.CallKitUI;
+import com.netease.yunxin.nertc.ui.CallKitUIOptions;
+
+public class MainActivity extends AppCompatActivity {
+
+    private static final String TAG = MainActivity.class.getSimpleName();
+
+    private TextView tvVersion;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+        initView();
+        checkLogin();
+        initG2();
+        dumpTest();
+    }
+
+    private void dumpTest() {
+        if (BuildConfig.DEBUG) {
+            findViewById(R.id.btn).setOnClickListener(v -> {
+                ToastUtils.showLong("开始dump音频");
+                NERtcEx.getInstance().startAudioDump();
+            });
+            findViewById(R.id.btn2).setOnClickListener(v -> {
+                ToastUtils.showLong("dump已结束,请到/sdcard/Android/data/com.netease.videocall.demo/files/dump目录查看dump文件");
+                NERtcEx.getInstance().stopAudioDump();
+            });
+        } else {
+            findViewById(R.id.btn).setVisibility(View.GONE);
+            findViewById(R.id.btn2).setVisibility(View.GONE);
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+    }
+
+    private void initG2() {
+
+        NIMClient.getService(AuthServiceObserver.class).observeOnlineStatus(new Observer<StatusCode>() {
+            @Override
+            public void onEvent(StatusCode statusCode) {
+                if (statusCode == StatusCode.LOGINED) {
+
+                    CallKitUIOptions options = new CallKitUIOptions.Builder()
+                            // 音视频通话 sdk appKey,用于通话中使用
+                            .rtcAppKey(BuildConfig.APP_KEY)
+                            // 当前用户 accId
+                            .currentUserAccId(ProfileManager.getInstance().getUserModel().imAccid)
+                            // 通话接听成功的超时时间单位 毫秒,默认30s
+                            .timeOutMillisecond(30 * 1000L)
+                            // 当系统版本为 Android Q及以上时,若应用在后台系统限制不直接展示页面
+                            // 而是展示 notification,通过点击 notification 跳转呼叫页面
+                            // 此处为 notification 相关配置,如图标,提示语等。
+                            .notificationConfigFetcher(new SelfNotificationConfigFetcher())
+                            // 收到被叫时若 app 在后台,在恢复到前台时是否自动唤起被叫页面,默认为 true
+                            .resumeBGInvitation(true)
+                            // 设置初始化 rtc sdk 相关配置,按照所需进行配置
+                            .rtcSdkOption(new NERtcOption())
+                            // 设置用户信息
+                            .userInfoHelper(new SelfUserInfoHelper())
+                            .build();
+                    // 若重复初始化会销毁之前的初始化实例,重新初始化
+                    CallKitUI.init(getApplicationContext(), options);
+                }
+            }
+        }, true);
+    }
+
+    private void checkLogin() {
+        if (ProfileManager.getInstance().isLogin()) {
+            return;
+        }
+        //此处注册之后会立刻回调一次
+        NIMClient.getService(AuthServiceObserver.class).observeOnlineStatus((Observer<StatusCode>) statusCode -> {
+            if (statusCode == StatusCode.LOGINED) {
+                ProfileManager.getInstance().setLogin(true);
+                CallOrderManager.getInstance().init();
+            }
+        }, true);
+
+
+    }
+
+    private void initView() {
+        ImageView ivAccountIcon = findViewById(R.id.iv_account);
+        RelativeLayout rlyVideoCall = findViewById(R.id.rly_video_call);
+        tvVersion = findViewById(R.id.tv_version);
+
+        ivAccountIcon.setOnClickListener(view -> {
+            if (ProfileManager.getInstance().isLogin()) {
+                showLogoutDialog();
+            } else {
+                LoginActivity.startLogin(this);
+            }
+        });
+
+        rlyVideoCall.setOnClickListener(view -> {
+            if (!ProfileManager.getInstance().isLogin()) {
+                LoginActivity.startLogin(this);
+            } else {
+                NERTCSelectCallUserActivity.startSelectUser(this);
+            }
+        });
+
+        initVersionInfo();
+    }
+
+    private void initVersionInfo() {
+        String versionInfo = "NIM sdk version:" + NIMClient.getSDKVersion() + "\nnertc sdk version:" +
+                NERtc.version().versionName + "\ncallKit version:" + CallKitUI.INSTANCE.currentVersion();
+        tvVersion.setText(versionInfo);
+    }
+
+    private void showLogoutDialog() {
+        final AlertDialog.Builder confirmDialog =
+                new AlertDialog.Builder(MainActivity.this);
+        confirmDialog.setTitle("注销账户:" + ProfileManager.getInstance().getUserModel().mobile);
+        confirmDialog.setMessage("确认注销当前登录账号?");
+        confirmDialog.setPositiveButton("是",
+                (dialog, which) -> {
+                    ProfileManager.getInstance().logout();
+                    ToastUtils.showLong("已经退出登录");
+                });
+        confirmDialog.setNegativeButton("否",
+                (dialog, which) -> {
+
+                });
+        confirmDialog.show();
+    }
+}

+ 33 - 0
app/src/main/java/com/netease/yunxin/app/videocall/SelfNotificationConfigFetcher.java

@@ -0,0 +1,33 @@
+package com.netease.yunxin.app.videocall;
+
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.uinfo.UserService;
+import com.netease.nimlib.sdk.uinfo.model.NimUserInfo;
+import com.netease.yunxin.kit.alog.ALog;
+import com.netease.yunxin.nertc.nertcvideocall.bean.InvitedInfo;
+import com.netease.yunxin.nertc.ui.CallKitNotificationConfig;
+
+import org.json.JSONObject;
+
+import kotlin.jvm.functions.Function1;
+
+class SelfNotificationConfigFetcher implements Function1<InvitedInfo, CallKitNotificationConfig> {
+    @Override
+    public CallKitNotificationConfig invoke(InvitedInfo invitedInfo) {
+        String name;
+        NimUserInfo userInfo = NIMClient.getService(UserService.class).getUserInfo(invitedInfo.invitor);
+        if (userInfo != null) {
+            name = userInfo.getName();
+        } else {
+            String extraInfo = invitedInfo.attachment;
+            try {
+                JSONObject object = new JSONObject(extraInfo);
+                name = object.optString("userName");
+            } catch (Exception exception) {
+                ALog.e("SelfNotificationConfigFetcher", "parse inviteInfo extra error.", exception);
+                name = "";
+            }
+        }
+        return new CallKitNotificationConfig(R.mipmap.ic_launcher, null, "您有新的来电", name + "邀请您进行【网络通话】");
+    }
+}

+ 52 - 0
app/src/main/java/com/netease/yunxin/app/videocall/SelfUserInfoHelper.java

@@ -0,0 +1,52 @@
+package com.netease.yunxin.app.videocall;
+
+import android.content.Context;
+import android.widget.ImageView;
+
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.RequestCallbackWrapper;
+import com.netease.nimlib.sdk.uinfo.UserService;
+import com.netease.nimlib.sdk.uinfo.model.NimUserInfo;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+import com.netease.yunxin.app.videocall.nertc.biz.UserCacheManager;
+import com.netease.yunxin.nertc.ui.base.UserInfoHelper;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.List;
+
+import kotlin.Unit;
+import kotlin.jvm.functions.Function1;
+
+class SelfUserInfoHelper implements UserInfoHelper {
+    @Override
+    public boolean fetchNickname(@NotNull String accId, @NotNull Function1<? super String, Unit> function1) {
+        UserModel userModel = UserCacheManager.getInstance().getUserModelFromAccId(accId);
+        if (userModel != null) {
+            function1.invoke(userModel.mobile + "");
+        } else {
+            NIMClient.getService(UserService.class).fetchUserInfo(Collections.singletonList(accId)).setCallback(new RequestCallbackWrapper<List<NimUserInfo>>() {
+                @Override
+                public void onResult(int code, List<NimUserInfo> result, Throwable exception) {
+                    if (result == null || result.isEmpty()) {
+                        function1.invoke(accId);
+                        return;
+                    }
+                    function1.invoke(result.get(0).getMobile() + "");
+                }
+            });
+        }
+        return true;
+    }
+
+    @Override
+    public boolean fetchNicknameByTeam(@NotNull String s, @NotNull String s1, @NotNull Function1<? super String, Unit> function1) {
+        return false;
+    }
+
+    @Override
+    public boolean loadAvatar(@NotNull Context context, @NotNull String s, @NotNull ImageView imageView) {
+        return false;
+    }
+}

+ 37 - 0
app/src/main/java/com/netease/yunxin/app/videocall/SplashActivity.java

@@ -0,0 +1,37 @@
+package com.netease.yunxin.app.videocall;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+public class SplashActivity extends AppCompatActivity {
+
+    private static final String TAG = "SplashActivity";
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        if (!isTaskRoot()){
+            finish();
+            return;
+        }
+        navigationMain();
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        Log.d(TAG, "onNewIntent: intent -> " + intent.getData());
+        setIntent(intent);
+        navigationMain();
+    }
+
+    private void navigationMain() {
+        Intent intent = new Intent(this, MainActivity.class);
+        startActivity(intent);
+        finish();
+    }
+}

+ 79 - 0
app/src/main/java/com/netease/yunxin/app/videocall/base/BaseService.java

@@ -0,0 +1,79 @@
+package com.netease.yunxin.app.videocall.base;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+
+import com.netease.yunxin.app.videocall.BuildConfig;
+
+import java.io.IOException;
+
+import okhttp3.Interceptor;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.logging.HttpLoggingInterceptor;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+public class BaseService {
+
+    private final Retrofit mRetrofit;
+
+    public static final int ERROR_CODE_UNKNOWN = -1;
+
+    private static final String BASE_URL = BuildConfig.BASE_URL;
+
+    public static BaseService getInstance() {
+        return RetrofitHolder.retrofit;
+    }
+
+    static class RetrofitHolder {
+        static BaseService retrofit = new BaseService();
+    }
+
+    private BaseService() {
+        HttpLoggingInterceptor interceptor =  new HttpLoggingInterceptor(message -> Log.d("======>>>", message));
+        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
+        OkHttpClient.Builder builder = new OkHttpClient.Builder()
+                .addInterceptor(interceptor)
+                .addInterceptor(new Interceptor() {
+                    @Override
+                    public okhttp3.Response intercept(Chain chain) throws IOException {
+                        Request original = chain.request();
+
+                        // Request customization: add request headers
+
+                        Request.Builder requestBuilder = original.newBuilder();
+                        if (!TextUtils.isEmpty(CommonDataManager.getInstance().getAccessToken())) {
+                            requestBuilder.header("accessToken", CommonDataManager.getInstance().getAccessToken());
+                        }
+
+                        requestBuilder.addHeader("appkey", BuildConfig.APP_KEY);
+
+
+                        Request request = requestBuilder.build();
+                        return chain.proceed(request);
+                    }
+                });
+        mRetrofit = new Retrofit.Builder()
+                .baseUrl(BASE_URL)
+                .client(builder.build())
+                .addConverterFactory(GsonConverterFactory.create())
+                .build();
+    }
+
+    public Retrofit getRetrofit() {
+        return mRetrofit;
+    }
+
+    public static class ResponseEntity<T> {
+        public int code;
+        public T data;
+    }
+
+    public interface ResponseCallBack<T> {
+        void onSuccess(T response);
+
+        void onFail(int code);
+    }
+}

+ 56 - 0
app/src/main/java/com/netease/yunxin/app/videocall/base/CommonDataManager.java

@@ -0,0 +1,56 @@
+package com.netease.yunxin.app.videocall.base;
+
+import com.blankj.utilcode.util.SPUtils;
+
+public class CommonDataManager {
+
+    private static final CommonDataManager instance = new CommonDataManager();
+
+    public static CommonDataManager getInstance() {
+        return instance;
+    }
+
+    public final static String PER_DATA = "per_profile_manager";
+    private static final String PER_ACCESS_TOKEN = "per_access_token";
+    private static final String PER_IM_TOKEN = "per_im_token";
+
+    private String token;
+
+    private String imToken;
+
+    private CommonDataManager() {
+    }
+
+    public String getAccessToken() {
+        if (token == null) {
+            loadAccessToken();
+        }
+        return token;
+    }
+
+    public void setAccessToken(String token) {
+        this.token = token;
+        SPUtils.getInstance(PER_DATA).put(PER_ACCESS_TOKEN, this.token);
+    }
+
+    private void loadAccessToken() {
+        token = SPUtils.getInstance(PER_DATA).getString(PER_ACCESS_TOKEN, "");
+    }
+
+    public String getIMToken() {
+        if (imToken == null) {
+            loadAccessToken();
+        }
+        return imToken;
+    }
+
+    public void setIMToken(String imToken) {
+        this.imToken = imToken;
+        SPUtils.getInstance(PER_DATA).put(PER_IM_TOKEN, this.imToken);
+    }
+
+    private void loadIMToken() {
+        imToken = SPUtils.getInstance(PER_DATA).getString(PER_IM_TOKEN, "");
+    }
+
+}

+ 283 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/model/LoginServiceManager.java

@@ -0,0 +1,283 @@
+package com.netease.yunxin.app.videocall.login.model;
+
+import static com.netease.yunxin.app.videocall.base.BaseService.ERROR_CODE_UNKNOWN;
+
+import android.text.TextUtils;
+
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.RequestCallback;
+import com.netease.nimlib.sdk.auth.LoginInfo;
+import com.netease.nimlib.sdk.uinfo.UserService;
+import com.netease.nimlib.sdk.uinfo.constant.UserInfoFieldEnum;
+import com.netease.yunxin.app.videocall.base.BaseService;
+import com.netease.yunxin.app.videocall.base.CommonDataManager;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+import retrofit2.http.Body;
+import retrofit2.http.Headers;
+import retrofit2.http.POST;
+
+
+/**
+ * 登录服务管理
+ */
+public class LoginServiceManager {
+
+    private static final LoginServiceManager mOurInstance = new LoginServiceManager();
+
+    private final Api mApi;
+
+    private Call<BaseService.ResponseEntity<Void>> sendMessageCall;
+    private Call<BaseService.ResponseEntity<UserModel>> msmLoginCall;
+    private Call<BaseService.ResponseEntity<UserModel>> tokenLoginCall;
+    private Call<BaseService.ResponseEntity<Void>> logoutCall;
+
+    public static LoginServiceManager getInstance() {
+        return mOurInstance;
+    }
+
+    private LoginServiceManager() {
+        mApi = BaseService.getInstance().getRetrofit().create(Api.class);
+    }
+
+    /**
+     * 网络访问接口
+     */
+    private interface Api {
+        @POST("/auth/sendLoginSmsCode")
+        @Headers("Content-Type: application/json")
+        Call<BaseService.ResponseEntity<Void>> sendLoginSmsCode(@Body RequestBody body);
+
+
+        @POST("/auth/loginBySmsCode")
+        @Headers("Content-Type: application/json")
+        Call<BaseService.ResponseEntity<UserModel>> loginBySmsCode(@Body RequestBody body);
+
+        @POST("/auth/loginByToken")
+        @Headers("Content-Type: application/json")
+        Call<BaseService.ResponseEntity<UserModel>> loginByToken();
+
+        @POST("/auth/logout")
+        @Headers("Content-Type: application/json")
+        Call<BaseService.ResponseEntity<Void>> logout();
+
+    }
+
+
+    /**
+     * 发送验证码短信
+     *
+     * @param phoneNumber
+     * @param callBack
+     */
+    public void sendMessage(String phoneNumber, BaseService.ResponseCallBack<Void> callBack) {
+        if (sendMessageCall != null && sendMessageCall.isExecuted()) {
+            sendMessageCall.cancel();
+        }
+        JSONObject result = new JSONObject();
+        try {
+            result.put("mobile", phoneNumber);
+        } catch (JSONException e) {
+            e.printStackTrace();
+        }
+        RequestBody body = RequestBody.create(MediaType.parse("application/json"), result.toString());
+        sendMessageCall = mApi.sendLoginSmsCode(body);
+        sendMessageCall.enqueue(new Callback<BaseService.ResponseEntity<Void>>() {
+            @Override
+            public void onResponse(Call<BaseService.ResponseEntity<Void>> call, Response<BaseService.ResponseEntity<Void>> response) {
+                if (callBack != null) {
+                    BaseService.ResponseEntity<Void> responseEntity = response.body();
+                    if (responseEntity != null) {
+                        if (responseEntity.code == 200) {
+                            callBack.onSuccess(null);
+                        } else {
+                            callBack.onFail(responseEntity.code);
+                        }
+                    } else {
+                        callBack.onFail(response.code());
+                    }
+                }
+            }
+
+            @Override
+            public void onFailure(Call<BaseService.ResponseEntity<Void>> call, Throwable t) {
+                if (callBack != null) {
+                    callBack.onFail(ERROR_CODE_UNKNOWN);
+                }
+            }
+        });
+    }
+
+    /**
+     * 短信验证码登录
+     *
+     * @param phoneNumber
+     * @param smsCode
+     * @param callBack
+     */
+    public void loginWithSms(String phoneNumber, String smsCode, BaseService.ResponseCallBack<Void> callBack) {
+        if (msmLoginCall != null && msmLoginCall.isExecuted()) {
+            msmLoginCall.cancel();
+        }
+        JSONObject result = new JSONObject();
+        try {
+            result.put("mobile", phoneNumber);
+            result.put("smsCode", smsCode);
+        } catch (JSONException e) {
+            e.printStackTrace();
+        }
+        RequestBody body = RequestBody.create(MediaType.parse("application/json"), result.toString());
+        msmLoginCall = mApi.loginBySmsCode(body);
+        msmLoginCall.enqueue(new Callback<BaseService.ResponseEntity<UserModel>>() {
+            @Override
+            public void onResponse(Call<BaseService.ResponseEntity<UserModel>> call, Response<BaseService.ResponseEntity<UserModel>> response) {
+                if (callBack != null) {
+                    BaseService.ResponseEntity<UserModel> responseEntity = response.body();
+                    if (responseEntity.code == 200) {
+                        saveUserModel(responseEntity.data);
+                        UserModel userModel = responseEntity.data;
+                        loginNim(userModel, callBack);
+                    } else {
+                        callBack.onFail(responseEntity.code);
+                    }
+                }
+            }
+
+            @Override
+            public void onFailure(Call<BaseService.ResponseEntity<UserModel>> call, Throwable t) {
+                if (callBack != null) {
+                    callBack.onFail(ERROR_CODE_UNKNOWN);
+                }
+            }
+        });
+    }
+
+    /**
+     * 业务token登录接口,IM在初始化的时候设置已有userInfo 可以自动登录
+     *
+     * @param callBack
+     */
+    public void loginWithToken(BaseService.ResponseCallBack<Void> callBack) {
+        if (tokenLoginCall != null && tokenLoginCall.isExecuted()) {
+            tokenLoginCall.cancel();
+        }
+        tokenLoginCall = mApi.loginByToken();
+        tokenLoginCall.enqueue(new Callback<BaseService.ResponseEntity<UserModel>>() {
+            @Override
+            public void onResponse(Call<BaseService.ResponseEntity<UserModel>> call, Response<BaseService.ResponseEntity<UserModel>> response) {
+                if (callBack != null) {
+                    BaseService.ResponseEntity<UserModel> responseEntity = response.body();
+                    if (responseEntity.code == 200) {
+                        saveUserModel(responseEntity.data);
+                        UserModel userModel = responseEntity.data;
+                        loginNim(userModel, callBack);
+                    } else {
+                        callBack.onFail(responseEntity.code);
+                    }
+                }
+            }
+
+            @Override
+            public void onFailure(Call<BaseService.ResponseEntity<UserModel>> call, Throwable t) {
+                if (callBack != null) {
+                    callBack.onFail(ERROR_CODE_UNKNOWN);
+                }
+            }
+        });
+    }
+
+    /**
+     * 退出登录
+     *
+     * @param callBack
+     */
+    public void logout(BaseService.ResponseCallBack<Void> callBack) {
+        if (logoutCall != null && logoutCall.isExecuted()) {
+            logoutCall.cancel();
+        }
+        logoutCall = mApi.logout();
+        logoutCall.enqueue(new Callback<BaseService.ResponseEntity<Void>>() {
+            @Override
+            public void onResponse(Call<BaseService.ResponseEntity<Void>> call, Response<BaseService.ResponseEntity<Void>> response) {
+                if (callBack != null) {
+                    BaseService.ResponseEntity<Void> responseEntity = response.body();
+                    if (responseEntity.code == 200) {
+                        clearLoginInfo();
+                        ProfileManager.getInstance().logout();
+                        callBack.onSuccess(null);
+                    } else {
+                        callBack.onFail(responseEntity.code);
+                    }
+                }
+            }
+
+            @Override
+            public void onFailure(Call<BaseService.ResponseEntity<Void>> call, Throwable t) {
+                if (callBack != null) {
+                    callBack.onFail(ERROR_CODE_UNKNOWN);
+                }
+            }
+        });
+    }
+
+    /**
+     * 登录IM
+     *
+     * @param userModel
+     * @param callBack
+     */
+    private void loginNim(UserModel userModel, BaseService.ResponseCallBack<Void> callBack) {
+        LoginInfo loginInfo = new LoginInfo(String.valueOf(userModel.imAccid), userModel.imToken);
+        ProfileManager.getInstance().login(loginInfo, new RequestCallback<LoginInfo>() {
+            @Override
+            public void onSuccess(LoginInfo param) {
+                callBack.onSuccess(null);
+                ProfileManager.getInstance().setLogin(true);//登录IM成功
+                ProfileManager.getInstance().updateUserInfo(userModel);
+                Map<UserInfoFieldEnum, Object> fields = new HashMap<>(1);
+                fields.put(UserInfoFieldEnum.Name, userModel.mobile);
+                NIMClient.getService(UserService.class).updateUserInfo(fields);
+            }
+
+            @Override
+            public void onFailed(int code) {
+                callBack.onFail(code);
+            }
+
+            @Override
+            public void onException(Throwable exception) {
+                callBack.onFail(-1);
+            }
+        });
+    }
+
+    private void saveUserModel(UserModel userModel) {
+        if (userModel != null) {
+            ProfileManager.getInstance().setUserModel(userModel);
+            if (!TextUtils.isEmpty(userModel.accessToken)) {
+                ProfileManager.getInstance().setAccessToken(userModel.accessToken);
+            }
+            if (!TextUtils.isEmpty(userModel.imToken)) {
+                CommonDataManager.getInstance().setIMToken(userModel.imToken);
+            }
+        }
+    }
+
+    private void clearLoginInfo() {
+        ProfileManager.getInstance().setLogin(false);
+        ProfileManager.getInstance().setUserModel(null);
+        ProfileManager.getInstance().setAccessToken(null);
+        CommonDataManager.getInstance().setIMToken(null);
+    }
+
+}

+ 151 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/model/ProfileManager.java

@@ -0,0 +1,151 @@
+package com.netease.yunxin.app.videocall.login.model;
+
+import android.text.TextUtils;
+
+import com.blankj.utilcode.util.GsonUtils;
+import com.blankj.utilcode.util.SPUtils;
+import com.netease.nimlib.sdk.AbortableFuture;
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.RequestCallback;
+import com.netease.nimlib.sdk.auth.AuthService;
+import com.netease.nimlib.sdk.auth.LoginInfo;
+import com.netease.nimlib.sdk.uinfo.UserService;
+import com.netease.nimlib.sdk.uinfo.constant.UserInfoFieldEnum;
+import com.netease.yunxin.app.videocall.base.CommonDataManager;
+import com.netease.yunxin.nertc.nertcvideocall.model.UserInfoInitCallBack;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public final class ProfileManager implements UserInfoInitCallBack {
+    private static final ProfileManager instance = new ProfileManager();
+
+    public static ProfileManager getInstance() {
+        return instance;
+    }
+
+    private final static String PER_USER_MODEL = "per_user_model";
+
+    private UserModel userModel;
+    private String token;
+    private boolean isLogin = false;
+
+    private AbortableFuture<LoginInfo> loginRequest;
+
+    private ProfileManager() {
+    }
+
+    public boolean isLogin() {
+        return isLogin;
+    }
+
+    public void setLogin(boolean login) {
+        isLogin = login;
+    }
+
+    public UserModel getUserModel() {
+        if (userModel == null) {
+            loadUserModel();
+        }
+        return userModel;
+    }
+
+    /**
+     * 是否是本用户
+     *
+     * @param imAccId
+     * @return
+     */
+    public boolean isCurrentUser(String imAccId) {
+        if (getUserModel() == null) {
+            return false;
+        }
+        return TextUtils.equals(getUserModel().imAccid,imAccId);
+    }
+
+    /**
+     * 音视频uid 判断
+     * @param g2Uid
+     * @return
+     */
+    public boolean isCurrentUser(long g2Uid) {
+        if (getUserModel() == null) {
+            return false;
+        }
+        return getUserModel().avRoomUid == g2Uid;
+    }
+
+    public String getAccessToken() {
+        if (token == null) {
+            loadAccessToken();
+        }
+        return token;
+    }
+
+    public void setUserModel(UserModel model) {
+        userModel = model;
+        saveUserModel();
+    }
+
+    public void setAccessToken(String token) {
+        this.token = token;
+        CommonDataManager.getInstance().setAccessToken(token);
+    }
+
+    private void loadAccessToken() {
+        token = CommonDataManager.getInstance().getAccessToken();
+    }
+
+    private void loadUserModel() {
+        try {
+            String json = SPUtils.getInstance(CommonDataManager.PER_DATA).getString(PER_USER_MODEL);
+            userModel = GsonUtils.fromJson(json, UserModel.class);
+        } catch (Exception e) {
+        }
+    }
+
+    private void saveUserModel() {
+        try {
+            if (userModel != null) {
+                SPUtils.getInstance(CommonDataManager.PER_DATA).put(PER_USER_MODEL, GsonUtils.toJson(userModel));
+            } else {
+                SPUtils.getInstance(CommonDataManager.PER_DATA).put(PER_USER_MODEL, "");
+            }
+        } catch (Exception e) {
+        }
+    }
+
+    public void login(LoginInfo loginInfo, RequestCallback<LoginInfo> callback) {
+        loginRequest = NIMClient.getService(AuthService.class).login(loginInfo);
+        loginRequest.setCallback(callback);
+    }
+
+    public void updateUserInfo(UserModel userModel) {
+        Map<UserInfoFieldEnum, Object> userInfo = new HashMap<>();
+        userInfo.put(UserInfoFieldEnum.AVATAR, userModel.avatar);
+        userInfo.put(UserInfoFieldEnum.MOBILE, userModel.mobile);
+        NIMClient.getService(UserService.class).updateUserInfo(userInfo);
+    }
+
+    public void logout() {
+        if (!isLogin) {
+            return;
+        }
+        isLogin = false;
+        userModel = null;
+        token = null;
+        NIMClient.getService(AuthService.class).logout();
+        SPUtils.getInstance(CommonDataManager.PER_DATA).put(PER_USER_MODEL, "");
+    }
+
+    @Override
+    public void onUserLoginToIm(String imAccId, String imToken) {
+        UserModel userModel = getUserModel();
+        if (userModel==null){
+            userModel=new UserModel();
+        }
+        userModel.imAccid = imAccId;
+        userModel.imToken = imToken;
+        setUserModel(userModel);
+    }
+}

+ 26 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/model/UserModel.java

@@ -0,0 +1,26 @@
+package com.netease.yunxin.app.videocall.login.model;
+
+import android.text.TextUtils;
+
+import java.io.Serializable;
+
+/**
+ * 业务用户数据
+ */
+public final class UserModel implements Serializable {
+    public String mobile;//String  登录的手机号
+    public String accessToken;//String  登录令牌,重新生成的新令牌,过期时间重新计算
+    public String imAccid;//long  IM账号
+    public String imToken;//String  IM令牌,重新生成的新令牌
+    public long avRoomUid;//String  音视频房间内成员编号
+    public String avatar;//String  头像地址
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        UserModel userModel = (UserModel) o;
+        return TextUtils.equals(this.mobile, userModel.mobile);
+    }
+
+}

+ 95 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/ui/LoginActivity.java

@@ -0,0 +1,95 @@
+package com.netease.yunxin.app.videocall.login.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.widget.Button;
+import android.widget.EditText;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.blankj.utilcode.util.NetworkUtils;
+import com.blankj.utilcode.util.ToastUtils;
+import com.netease.yunxin.app.videocall.R;
+import com.netease.yunxin.app.videocall.login.model.LoginServiceManager;
+import com.netease.yunxin.app.videocall.base.BaseService;
+
+public class LoginActivity extends AppCompatActivity {
+
+    private EditText mEdtPhoneNumber;
+    private Button mBtnSendMessage;
+    private static final int PHONE_NUMBER_MAX_LENGTH=11;
+
+    public static void startLogin(Context context) {
+        Intent intent = new Intent();
+        intent.setClass(context, LoginActivity.class);
+        context.startActivity(intent);
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.login_activity);
+        initView();
+    }
+
+    private void initView() {
+        mEdtPhoneNumber = findViewById(R.id.edt_phone_number);
+        mBtnSendMessage = findViewById(R.id.btn_send);
+        //置灰效果
+//        mEdtPhoneNumber.addTextChangedListener(new TextWatcher() {
+//            @Override
+//            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+//
+//            }
+//
+//            @Override
+//            public void onTextChanged(CharSequence s, int start, int before, int count) {
+//                if (!TextUtils.isEmpty(s) && s.length() == PHONE_NUMBER_MAX_LENGTH) {
+//                    mBtnSendMessage.setEnabled(true);
+//                } else {
+//                    mBtnSendMessage.setEnabled(false);
+//                }
+//            }
+//
+//            @Override
+//            public void afterTextChanged(Editable s) {
+//
+//            }
+//        });
+        mBtnSendMessage.setOnClickListener(v -> {
+            sendMsm();
+        });
+
+    }
+
+    private void sendMsm() {
+        String phoneNumber = mEdtPhoneNumber.getText().toString().trim();
+        if (!TextUtils.isEmpty(phoneNumber)) {
+            if (phoneNumber.length() < PHONE_NUMBER_MAX_LENGTH) {
+                ToastUtils.showShort(R.string.login_phone_number_cant_less_than_eleven);
+                return;
+            }
+            if (!NetworkUtils.isConnected()){
+                ToastUtils.showShort(R.string.network_connect_error_please_try_again);
+                return;
+            }
+            LoginServiceManager.getInstance().sendMessage(phoneNumber, new BaseService.ResponseCallBack<Void>() {
+
+                @Override
+                public void onSuccess(Void response) {
+                    VerifyCodeActivity.startVerifyCode(LoginActivity.this, phoneNumber);
+                }
+
+                @Override
+                public void onFail(int code) {
+
+                }
+            });
+        } else {
+            ToastUtils.showShort(R.string.login_phone_number_cant_null);
+        }
+    }
+}

+ 158 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/ui/VerifyCodeActivity.java

@@ -0,0 +1,158 @@
+package com.netease.yunxin.app.videocall.login.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.CountDownTimer;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.blankj.utilcode.util.NetworkUtils;
+import com.blankj.utilcode.util.ToastUtils;
+import com.netease.yunxin.app.videocall.R;
+import com.netease.yunxin.app.videocall.login.model.LoginServiceManager;
+import com.netease.yunxin.app.videocall.login.ui.view.VerifyCodeView;
+import com.netease.yunxin.app.videocall.base.BaseService;
+
+public class VerifyCodeActivity extends AppCompatActivity {
+
+    public static final String PHONE_NUMBER = "phone_number";
+
+    private VerifyCodeView verifyCodeView;//验证码输入框
+
+    private TextView tvMsmComment;
+
+    private Button btnNext;
+
+    private TextView tvTimeCountDown;
+
+    private String phoneNumber;
+
+    private CountDownTimer countDownTimer;
+
+    private TextView tvResendMsm;
+
+    private static final int SMS_CODE_LENGTH=4;
+    private static final int THOUSAND_MS=1000;
+    private static final int SIXTY_THOUSAND_MS=60000;
+
+    public static void startVerifyCode(Context context, String phoneNumber) {
+        Intent intent = new Intent();
+        intent.setClass(context, VerifyCodeActivity.class);
+        intent.putExtra(PHONE_NUMBER, phoneNumber);
+        context.startActivity(intent);
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.verify_code_layout);
+        initView();
+        initData();
+    }
+
+    private void initView() {
+        verifyCodeView = findViewById(R.id.vcv_sms);
+        tvMsmComment = findViewById(R.id.tv_msm_comment);
+        btnNext = findViewById(R.id.btn_next);
+        tvTimeCountDown = findViewById(R.id.tv_time_discount);
+        tvResendMsm = findViewById(R.id.tv_resend_msm);
+    }
+
+    private void initData() {
+        phoneNumber = getIntent().getStringExtra(PHONE_NUMBER);
+        tvMsmComment.setText(getString(R.string.login_sms_code_has_been_sent) + phoneNumber + getString(R.string.login_please_input_sms_code));
+        btnNext.setOnClickListener(v -> {
+            if (!NetworkUtils.isConnected()){
+                ToastUtils.showShort(R.string.network_connect_error_please_try_again);
+                return;
+            }
+            String smsCode = verifyCodeView.getResult();
+            if (!TextUtils.isEmpty(smsCode)&&smsCode.length()==SMS_CODE_LENGTH) {
+                login(smsCode);
+            }else {
+                ToastUtils.showShort(R.string.login_please_input_correct_sms_code);
+            }
+        });
+        tvTimeCountDown.setOnClickListener(v -> {
+            reSendMsm();
+        });
+
+        initCountDown();
+    }
+
+    private void initCountDown() {
+        tvTimeCountDown.setText(R.string.sixty_second);
+        tvResendMsm.setVisibility(View.VISIBLE);
+        tvTimeCountDown.setEnabled(false);
+
+        countDownTimer = new CountDownTimer(SIXTY_THOUSAND_MS, THOUSAND_MS) {
+            @Override
+            public void onTick(long l) {
+                tvTimeCountDown.setText((l / THOUSAND_MS) + getString(R.string.login_second));
+            }
+
+            @Override
+            public void onFinish() {
+                tvTimeCountDown.setText(R.string.login_resend);
+                tvTimeCountDown.setEnabled(true);
+                tvResendMsm.setVisibility(View.GONE);
+            }
+        };
+
+        countDownTimer.start();
+    }
+
+    private void reSendMsm() {
+        if (!NetworkUtils.isConnected()){
+            ToastUtils.showShort(R.string.network_connect_error_please_try_again);
+            return;
+        }
+        if (!TextUtils.isEmpty(phoneNumber)) {
+            LoginServiceManager.getInstance().sendMessage(phoneNumber, new BaseService.ResponseCallBack<Void>() {
+
+                @Override
+                public void onSuccess(Void response) {
+                    ToastUtils.showLong(R.string.login_sms_code_send_success);
+                    initCountDown();
+                }
+
+                @Override
+                public void onFail(int code) {
+                    ToastUtils.showLong(R.string.login_sms_code_send_fail);
+                }
+            });
+        }
+    }
+
+    private void login(String msmCode) {
+        if (!TextUtils.isEmpty(phoneNumber) && !TextUtils.isEmpty(msmCode)) {
+            LoginServiceManager.getInstance().loginWithSms(phoneNumber, msmCode, new BaseService.ResponseCallBack<Void>() {
+                @Override
+                public void onSuccess(Void response) {
+                    ToastUtils.showLong(R.string.login_success);
+                    startMainActivity();
+                }
+
+                @Override
+                public void onFail(int code) {
+                    ToastUtils.showLong(R.string.login_fail);
+                }
+            });
+        }
+    }
+
+    private void startMainActivity() {
+        Intent intent = new Intent();
+        intent.addCategory("android.intent.category.DEFAULT");
+        intent.setAction("com.nertc.g2.action.main");
+        startActivity(intent);
+        finish();
+    }
+
+}

+ 392 - 0
app/src/main/java/com/netease/yunxin/app/videocall/login/ui/view/VerifyCodeView.java

@@ -0,0 +1,392 @@
+package com.netease.yunxin.app.videocall.login.ui.view;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.os.Build;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.netease.yunxin.app.videocall.R;
+
+import java.lang.reflect.Field;
+
+public class VerifyCodeView extends LinearLayout implements TextWatcher, View.OnKeyListener, View.OnFocusChangeListener {
+
+    private Context mContext;
+    private OnCodeFinishListener onCodeFinishListener;
+
+    /**
+     * 输入框数量
+     */
+    private int mEtNumber;
+
+    /**
+     * 输入框的宽度
+     */
+    private int mEtWidth;
+
+    /**
+     * 文字颜色
+     */
+    private int mEtTextColor;
+
+    /**
+     * 文字大小
+     */
+    private float mEtTextSize;
+
+    /**
+     * 输入框背景
+     */
+    private int mEtTextBg;
+
+    /**
+     * 输入框间距
+     */
+    private int mEtSpacing;
+
+    /**
+     * 平分后的间距
+     */
+    private int mEtBisectSpacing;
+
+    /**
+     * 判断是否平分
+     */
+    private boolean isBisect;
+
+    /**
+     * 是否显示光标
+     */
+    private boolean cursorVisible;
+
+    /**
+     * 光标样式
+     */
+    private int mCursorDrawable;
+
+    /**
+     * 输入框宽度
+     */
+    private int mViewWidth;
+
+    /**
+     * 输入框间距
+     */
+    private int mViewMargin;
+
+    public OnCodeFinishListener getOnCodeFinishListener() {
+        return onCodeFinishListener;
+    }
+
+    public void setOnCodeFinishListener(OnCodeFinishListener onCodeFinishListener) {
+        this.onCodeFinishListener = onCodeFinishListener;
+    }
+
+    public int getmEtNumber() {
+        return mEtNumber;
+    }
+
+    public void setmEtNumber(int mEtNumber) {
+        this.mEtNumber = mEtNumber;
+    }
+
+    public int getmEtWidth() {
+        return mEtWidth;
+    }
+
+    public void setmEtWidth(int mEtWidth) {
+        this.mEtWidth = mEtWidth;
+    }
+
+    public int getmEtTextColor() {
+        return mEtTextColor;
+    }
+
+    public void setmEtTextColor(int mEtTextColor) {
+        this.mEtTextColor = mEtTextColor;
+    }
+
+    public float getmEtTextSize() {
+        return mEtTextSize;
+    }
+
+    public void setmEtTextSize(float mEtTextSize) {
+        this.mEtTextSize = mEtTextSize;
+    }
+
+    public int getmEtTextBg() {
+        return mEtTextBg;
+    }
+
+    public void setmEtTextBg(int mEtTextBg) {
+        this.mEtTextBg = mEtTextBg;
+    }
+
+    public int getmCursorDrawable() {
+        return mCursorDrawable;
+    }
+
+    public void setmCursorDrawable(int mCursorDrawable) {
+        this.mCursorDrawable = mCursorDrawable;
+    }
+
+    public VerifyCodeView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+        this.mContext = context;
+        @SuppressLint({"Recycle", "CustomViewStyleable"})
+        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.vericationCodeView);
+        mEtNumber = typedArray.getInteger(R.styleable.vericationCodeView_vcv_et_number, 4);
+        mEtWidth = typedArray.getDimensionPixelSize(R.styleable.vericationCodeView_vcv_et_width, 120);
+        mEtTextColor = typedArray.getColor(R.styleable.vericationCodeView_vcv_et_text_color, Color.BLACK);
+        mEtTextSize = typedArray.getDimensionPixelSize(R.styleable.vericationCodeView_vcv_et_text_size, 16);
+        mEtTextBg = typedArray.getResourceId(R.styleable.vericationCodeView_vcv_et_bg, R.drawable.et_login_code);
+        mCursorDrawable = typedArray.getResourceId(R.styleable.vericationCodeView_vcv_et_cursor, R.drawable.et_cursor);
+        cursorVisible = typedArray.getBoolean(R.styleable.vericationCodeView_vcv_et_cursor_visible, true);
+
+        isBisect = typedArray.hasValue(R.styleable.vericationCodeView_vcv_et_spacing);
+        if (isBisect) {
+            mEtSpacing = typedArray.getDimensionPixelSize(R.styleable.vericationCodeView_vcv_et_spacing, 0);
+        }
+        initView();
+        //释放资源
+        typedArray.recycle();
+    }
+
+    @SuppressLint("ResourceAsColor")
+    private void initView() {
+        for (int i = 0; i < mEtNumber; i++) {
+            EditText editText = new EditText(mContext);
+            initEditText(editText, i);
+            addView(editText);
+            //设置第一个editText获取焦点
+            if (i == 0) {
+                editText.setFocusable(true);
+            }
+        }
+    }
+
+    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+    private void initEditText(EditText editText, int i) {
+        editText.setLayoutParams(getETLayoutParams(i));
+        editText.setTextAlignment(TextView.TEXT_ALIGNMENT_CENTER);
+        editText.setGravity(Gravity.CENTER);
+        editText.setId(i);
+        editText.setCursorVisible(false);
+        editText.setMaxEms(1);
+        editText.setTextColor(mEtTextColor);
+        editText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mEtTextSize);
+        editText.setCursorVisible(cursorVisible);
+        editText.setMaxLines(1);
+        editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(1)});
+        editText.setInputType(InputType.TYPE_CLASS_NUMBER);
+        editText.setPadding(0, 0, 0, 0);
+        editText.setOnKeyListener(this);
+        editText.setBackgroundResource(mEtTextBg);
+        setEditTextCursorDrawable(editText);
+        editText.addTextChangedListener(this);
+        editText.setOnKeyListener(this);
+        editText.setOnFocusChangeListener(this);
+    }
+
+    /**
+     * 获取EditText 的 LayoutParams
+     */
+    public LayoutParams getETLayoutParams(int i) {
+        LayoutParams layoutParams = new LayoutParams(mEtWidth, mEtWidth);
+        if (!isBisect) {
+            //平分Margin,把第一个EditText跟最后一个EditText的间距同设为平分
+            mEtBisectSpacing = (mViewWidth - mEtNumber * mEtWidth) / (mEtNumber + 1);
+            if (i == 0) {
+                layoutParams.leftMargin = mEtBisectSpacing;
+                layoutParams.rightMargin = mEtBisectSpacing / 2;
+            } else if (i == mEtNumber - 1) {
+                layoutParams.leftMargin = mEtBisectSpacing / 2;
+                layoutParams.rightMargin = mEtBisectSpacing;
+            } else {
+                layoutParams.leftMargin = mEtBisectSpacing / 2;
+                layoutParams.rightMargin = mEtBisectSpacing / 2;
+            }
+        } else {
+            layoutParams.leftMargin = mEtSpacing / 2;
+            layoutParams.rightMargin = mEtSpacing / 2;
+        }
+
+        layoutParams.gravity = Gravity.CENTER;
+        return layoutParams;
+    }
+
+    public void setEditTextCursorDrawable(EditText editText) {
+        //修改光标的颜色(反射)
+        if (cursorVisible) {
+            try {
+                Field f = TextView.class.getDeclaredField("mCursorDrawableRes");
+                f.setAccessible(true);
+                f.set(editText, mCursorDrawable);
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+        mViewWidth = getMeasuredWidth();
+        updateETMargin();
+    }
+
+    private void updateETMargin() {
+        for (int i = 0; i < mEtNumber; i++) {
+            EditText editText = (EditText) getChildAt(i);
+            editText.setLayoutParams(getETLayoutParams(i));
+        }
+    }
+
+
+    @Override
+    public void onFocusChange(View view, boolean b) {
+        if (b) {
+            focus();
+        }
+    }
+
+    @Override
+    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+    }
+
+    @Override
+    public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+    }
+
+    @Override
+    public void afterTextChanged(Editable s) {
+        if (s.length() != 0) {
+            focus();
+        }
+        if (onCodeFinishListener != null) {
+            onCodeFinishListener.onTextChange(this, getResult());
+            //如果最后一个输入框有字符,则返回结果
+            EditText lastEditText = (EditText) getChildAt(mEtNumber - 1);
+            if (lastEditText.getText().length() > 0) {
+                onCodeFinishListener.onComplete(this, getResult());
+            }
+        }
+    }
+
+    @Override
+    public boolean onKey(View v, int keyCode, KeyEvent event) {
+        if (keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN) {
+            backFocus();
+        }
+        return false;
+    }
+
+    @Override
+    public void setEnabled(boolean enabled) {
+        int childCount = getChildCount();
+        for (int i = 0; i < childCount; i++) {
+            View child = getChildAt(i);
+            child.setEnabled(enabled);
+        }
+    }
+
+    /**
+     * 获取焦点
+     */
+    private void focus() {
+        int count = getChildCount();
+        EditText editText;
+        //利用for循环找出还最前面那个还没被输入字符的EditText,并把焦点移交给它。
+        for (int i = 0; i < count; i++) {
+            editText = (EditText) getChildAt(i);
+            if (editText.getText().length() < 1) {
+                if (cursorVisible) {
+                    editText.setCursorVisible(true);
+                } else {
+                    editText.setCursorVisible(false);
+                }
+                editText.requestFocus();
+                return;
+            } else {
+                editText.setCursorVisible(false);
+                if (i == count - 1) {
+                    editText.requestFocus();
+                }
+            }
+        }
+    }
+
+    private void backFocus() {
+        EditText editText;
+        //循环检测有字符的`editText`,把其置空,并获取焦点。
+        for (int i = mEtNumber - 1; i >= 0; i--) {
+            editText = (EditText) getChildAt(i);
+            if (editText.getText().length() >= 1) {
+                editText.setText("");
+                if (cursorVisible) {
+                    editText.setCursorVisible(true);
+                } else {
+                    editText.setCursorVisible(false);
+                }
+                editText.requestFocus();
+                return;
+            }
+        }
+    }
+
+    public String getResult() {
+        StringBuilder stringBuffer = new StringBuilder();
+        EditText editText;
+        for (int i = 0; i < mEtNumber; i++) {
+            editText = (EditText) getChildAt(i);
+            stringBuffer.append(editText.getText());
+        }
+        return stringBuffer.toString();
+    }
+
+    public interface OnCodeFinishListener {
+        /**
+         * 文本改变
+         */
+        void onTextChange(View view, String content);
+
+        /**
+         * 输入完成
+         */
+        void onComplete(View view, String content);
+    }
+
+    /**
+     * 清空验证码输入框
+     */
+    public void setEmpty() {
+        EditText editText;
+        for (int i = mEtNumber - 1; i >= 0; i--) {
+            editText = (EditText) getChildAt(i);
+            editText.setText("");
+            if (i == 0) {
+                if (cursorVisible) {
+                    editText.setCursorVisible(true);
+                } else {
+                    editText.setCursorVisible(false);
+                }
+                editText.requestFocus();
+            }
+        }
+    }
+}

+ 147 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/CallOrderManager.java

@@ -0,0 +1,147 @@
+package com.netease.yunxin.app.videocall.nertc.biz;
+
+import androidx.lifecycle.MutableLiveData;
+
+import com.blankj.utilcode.util.GsonUtils;
+import com.blankj.utilcode.util.SPUtils;
+import com.netease.nimlib.sdk.NIMClient;
+import com.netease.nimlib.sdk.Observer;
+import com.netease.nimlib.sdk.msg.MsgServiceObserve;
+import com.netease.nimlib.sdk.msg.attachment.MsgAttachment;
+import com.netease.nimlib.sdk.msg.attachment.NetCallAttachment;
+import com.netease.nimlib.sdk.msg.constant.MsgDirectionEnum;
+import com.netease.nimlib.sdk.msg.model.IMMessage;
+import com.netease.nimlib.sdk.uinfo.UserService;
+import com.netease.nimlib.sdk.uinfo.model.NimUserInfo;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+import com.netease.yunxin.app.videocall.nertc.model.CallOrder;
+import com.netease.yunxin.kit.alog.ALog;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+public class CallOrderManager {
+
+    private CallOrderManager() {
+    }
+
+    public static final int MAX_ORDER = 3;
+
+    private static final String RECENTLY_CALL_ORDERS = "recently_call_orders";
+
+    private static final String ORDER_LIST = "call_orders";
+
+    MutableLiveData<List<CallOrder>> ordersLiveData = new MutableLiveData<>();
+
+    List<CallOrder> orders = new ArrayList<>();
+
+    public static CallOrderManager getInstance() {
+        return ManagerHolder.manager;
+    }
+
+    private static final class ManagerHolder {
+        public static final CallOrderManager manager = new CallOrderManager();
+    }
+
+    public void init() {
+        register(true);
+        orders.clear();
+        readWriteLock = new ReentrantReadWriteLock();
+        loadOrders();
+    }
+
+    private ReentrantReadWriteLock readWriteLock;
+
+    public List<CallOrder> getOrders() {
+        return orders;
+    }
+
+    private void loadOrders() {
+        ExecutorService executorService = Executors.newSingleThreadExecutor();
+        executorService.submit(() -> {
+            try {
+                readWriteLock.writeLock().lock();
+                UserModel currentUser = ProfileManager.getInstance().getUserModel();
+                ALog.i("GeorgeTest", "loadOrders currentUser mobile:" + currentUser.mobile);
+                String orderStr = SPUtils.getInstance(RECENTLY_CALL_ORDERS + currentUser.mobile).getString(ORDER_LIST);
+                Type type = GsonUtils.getListType(CallOrder.class);
+                List<CallOrder> orderList = GsonUtils.fromJson(orderStr, type);
+                if (orderList != null && !orderList.isEmpty()) {
+                    int len = orderList.size() - orders.size();
+                    orders.addAll(orderList.subList(0, len));
+                    ordersLiveData.postValue(orders);
+                }
+            } catch (Exception exception) {
+                ALog.e("CallOrderManager", "loadOrders", exception);
+            } finally {
+                readWriteLock.writeLock().unlock();
+            }
+        });
+    }
+
+
+    public MutableLiveData<List<CallOrder>> getOrdersLiveData() {
+        return ordersLiveData;
+    }
+
+    Observer<List<IMMessage>> incomingMessageObserver =
+            (Observer<List<IMMessage>>) messages -> {
+                for (IMMessage msg : messages) {
+                    MsgAttachment attachment = msg.getAttachment();
+                    if (attachment instanceof NetCallAttachment) {
+                        NimUserInfo user = NIMClient.getService(UserService.class).getUserInfo(msg.getSessionId());
+                        if (user != null) {
+                            CallOrder callOrder = new CallOrder(msg.getSessionId(), msg.getTime(), msg.getDirect(), (NetCallAttachment) attachment, user.getMobile());
+                            addOrder(callOrder);
+                        }
+                    }
+                }
+            };
+
+    Observer<IMMessage> statusMessage =
+            (Observer<IMMessage>) message -> {
+                if (message.getDirect() == MsgDirectionEnum.Out) {
+                    MsgAttachment attachment = message.getAttachment();
+                    if (attachment instanceof NetCallAttachment) {
+                        NimUserInfo user = NIMClient.getService(UserService.class).getUserInfo(message.getSessionId());
+                        CallOrder order = new CallOrder(message.getSessionId(), message.getTime(), message.getDirect(), (NetCallAttachment) attachment, user.getMobile());
+                        addOrder(order);
+                    }
+                }
+            };
+
+    private void addOrder(CallOrder order) {
+        try{
+            readWriteLock.writeLock().lock();
+            if (orders.size() >= MAX_ORDER) {
+                orders.remove(0);
+            }
+            orders.add(order);
+            ordersLiveData.postValue(orders);
+        }catch (Exception exception){
+            ALog.e("CallOrderManager", "addOrder", exception);
+        }finally {
+            readWriteLock.writeLock().unlock();
+        }
+        uploadOrder();
+    }
+
+    private void uploadOrder() {
+        String orderStr = GsonUtils.toJson(orders);
+        UserModel currentUser = ProfileManager.getInstance().getUserModel();
+        ALog.i("GeorgeTest", "uploadOrder currentUser mobile:" + currentUser.mobile);
+        SPUtils.getInstance(RECENTLY_CALL_ORDERS + currentUser.mobile).put(ORDER_LIST, orderStr);
+    }
+
+    private void register(boolean register) {
+        NIMClient.getService(MsgServiceObserve.class)
+                .observeReceiveMessage(incomingMessageObserver, register);
+        NIMClient.getService(MsgServiceObserve.class)
+                .observeMsgStatus(statusMessage, register);
+    }
+}

+ 86 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/CallServiceManager.java

@@ -0,0 +1,86 @@
+package com.netease.yunxin.app.videocall.nertc.biz;
+
+
+import com.netease.yunxin.app.videocall.base.BaseService;
+import com.netease.yunxin.kit.alog.ALog;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+import retrofit2.http.Body;
+import retrofit2.http.POST;
+
+public class CallServiceManager {
+
+    private static final String LOG_TAG = "CallServiceManager";
+
+    private final CallApi mApi;
+
+    private Call<BaseService.ResponseEntity<UserModel>> searchUserCall;
+
+    private CallServiceManager() {
+        mApi = BaseService.getInstance().getRetrofit().create(CallApi.class);
+    }
+
+    public static CallServiceManager getInstance() {
+        return new CallServiceManager();
+    }
+
+
+    /**
+     * 网络访问接口
+     */
+    private interface CallApi {
+        @POST("/p2pVideoCall/caller/searchSubscriber")
+        Call<BaseService.ResponseEntity<UserModel>> searchUserWithPhoneNumber(@Body RequestBody body);
+    }
+
+    /**
+     * 根据手机号码精确搜索用户
+     *
+     * @param phoneNumber
+     * @param callBack
+     */
+    public void searchUserWithPhoneNumber(String phoneNumber, final BaseService.ResponseCallBack<UserModel> callBack) {
+        if (searchUserCall != null && searchUserCall.isExecuted()) {
+            ALog.e(LOG_TAG, "searchUserCall is executed");
+            searchUserCall.cancel();
+        }
+        JSONObject result = new JSONObject();
+        try {
+            result.put("mobile", phoneNumber);
+        } catch (JSONException e) {
+            e.printStackTrace();
+        }
+        RequestBody body = RequestBody.create(MediaType.parse("application/json"), result.toString());
+        searchUserCall = mApi.searchUserWithPhoneNumber(body);
+        searchUserCall.enqueue(new Callback<BaseService.ResponseEntity<UserModel>>() {
+            @Override
+            public void onResponse(Call<BaseService.ResponseEntity<UserModel>> call, Response<BaseService.ResponseEntity<UserModel>> response) {
+                if (callBack != null) {
+                    BaseService.ResponseEntity<UserModel> responseEntity = response.body();
+                    if (responseEntity.code == 200) {
+                        callBack.onSuccess(responseEntity.data);
+                    } else {
+                        callBack.onFail(responseEntity.code);
+                    }
+                }
+            }
+
+            @Override
+            public void onFailure(Call<BaseService.ResponseEntity<UserModel>> call, Throwable t) {
+                if (callBack != null) {
+                    callBack.onFail(BaseService.ERROR_CODE_UNKNOWN);
+                }
+            }
+        });
+
+    }
+
+}

+ 97 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/biz/UserCacheManager.java

@@ -0,0 +1,97 @@
+package com.netease.yunxin.app.videocall.nertc.biz;
+
+import com.blankj.utilcode.util.GsonUtils;
+import com.blankj.utilcode.util.SPUtils;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+
+public class UserCacheManager {
+
+    private List<UserModel> lastSearchUser;
+
+    private final Map<String, UserModel> userModelMap = new HashMap<>();
+
+    private static final String LAST_SEARCH_USER = "last_search_users";
+
+    private static final String USER_LIST = "user_list";
+
+    private static final int MAX_SIZE = 3;
+
+    public static UserCacheManager getInstance() {
+        return new UserCacheManager();
+    }
+
+    private UserCacheManager() {
+
+    }
+
+    public void getLastSearchUser(final GetUserCallBack callBack) {
+        ExecutorService executorService = Executors.newSingleThreadExecutor();
+        executorService.submit(new Runnable() {
+            @Override
+            public void run() {
+                if (lastSearchUser == null || lastSearchUser.size() == 0) {
+                    loadUsers();
+                }
+                callBack.getUser(lastSearchUser);
+            }
+        });
+    }
+
+    public void addUser(final UserModel userModel) {
+        if (userModel != null) {
+            userModelMap.put(userModel.imAccid, userModel);
+        }
+        ExecutorService executorService = Executors.newSingleThreadExecutor();
+        executorService.submit(new Runnable() {
+            @Override
+            public void run() {
+                if (lastSearchUser == null || lastSearchUser.size() == 0) {
+                    loadUsers();
+                }
+                if (lastSearchUser != null) {
+                    if (!lastSearchUser.contains(userModel)) {
+                        if (lastSearchUser.size() >= MAX_SIZE) {
+                            lastSearchUser.remove(lastSearchUser.get(0));
+                        }
+                        lastSearchUser.add(userModel);
+                    }
+                } else {
+                    lastSearchUser = new LinkedList<>();
+                    lastSearchUser.add(userModel);
+                }
+                uploadUsers();
+            }
+        });
+    }
+
+    public UserModel getUserModelFromAccId(String accId){
+        return userModelMap.get(accId);
+    }
+
+    private void loadUsers() {
+        UserModel currentUser = ProfileManager.getInstance().getUserModel();
+        String userStr = SPUtils.getInstance(LAST_SEARCH_USER + currentUser.mobile).getString(USER_LIST);
+        Type type = GsonUtils.getListType(UserModel.class);
+        lastSearchUser = GsonUtils.fromJson(userStr, type);
+    }
+
+    private void uploadUsers() {
+        UserModel currentUser = ProfileManager.getInstance().getUserModel();
+        String userStr = GsonUtils.toJson(lastSearchUser);
+        SPUtils.getInstance(LAST_SEARCH_USER + currentUser.mobile).put(USER_LIST, userStr);
+    }
+
+    public interface GetUserCallBack {
+        void getUser(List<UserModel> users);
+    }
+}

+ 26 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/model/CallOrder.java

@@ -0,0 +1,26 @@
+package com.netease.yunxin.app.videocall.nertc.model;
+
+import com.netease.nimlib.sdk.msg.attachment.NetCallAttachment;
+import com.netease.nimlib.sdk.msg.constant.MsgDirectionEnum;
+
+import java.io.Serializable;
+
+public class CallOrder implements Serializable {
+    public long receivedTime;//收到时间
+
+    public String nickname;//昵称
+
+    public String sessionId;//对方的imAccount
+
+    public MsgDirectionEnum direction;//方向
+
+    public NetCallAttachment attachment;
+
+    public CallOrder(String sessionId, long receivedTime, MsgDirectionEnum direction, NetCallAttachment attachment, String nickname) {
+        this.sessionId = sessionId;
+        this.receivedTime = receivedTime;
+        this.direction = direction;
+        this.attachment = attachment;
+        this.nickname = nickname;
+    }
+}

+ 192 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/NERTCSelectCallUserActivity.java

@@ -0,0 +1,192 @@
+package com.netease.yunxin.app.videocall.nertc.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.lifecycle.Observer;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.blankj.utilcode.util.NetworkUtils;
+import com.blankj.utilcode.util.ToastUtils;
+import com.netease.yunxin.app.videocall.R;
+import com.netease.yunxin.app.videocall.base.BaseService;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.nertc.biz.CallOrderManager;
+import com.netease.yunxin.app.videocall.nertc.biz.CallServiceManager;
+import com.netease.yunxin.app.videocall.nertc.ui.adapter.CallOrderAdapter;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+import com.netease.yunxin.app.videocall.nertc.biz.UserCacheManager;
+import com.netease.yunxin.app.videocall.nertc.model.CallOrder;
+import com.netease.yunxin.app.videocall.nertc.ui.adapter.RecentUserAdapter;
+
+import java.util.List;
+
+public class NERTCSelectCallUserActivity extends AppCompatActivity {
+
+    private TextView tvSelfNumber;
+
+    private RecyclerView rvRecentUser;
+    private RecentUserAdapter userAdapter;
+
+    private Button btnSearch;
+    private EditText edtPhoneNumber;
+    private ImageView ivClear;
+
+    private TextView tvRecentSearch;
+    private TextView tvEmpty;
+    private RecyclerView rvSearchResult;
+    private RecentUserAdapter searchAdapter;
+
+    private CallOrderAdapter callOrderAdapter;
+    private TextView tvCallRecord;
+
+
+    public static void startSelectUser(Context context) {
+        Intent intent = new Intent();
+        intent.setClass(context, NERTCSelectCallUserActivity.class);
+        context.startActivity(intent);
+    }
+
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.video_call_select_layout);
+        initView();
+        initData();
+    }
+
+
+    private void initView() {
+        btnSearch = findViewById(R.id.btn_search);
+        edtPhoneNumber = findViewById(R.id.edt_phone_number);
+        ivClear = findViewById(R.id.iv_clear);
+        rvRecentUser = findViewById(R.id.rv_recent_user);
+        RecyclerView rvCallOrder = findViewById(R.id.rv_call_order);
+        tvEmpty = findViewById(R.id.tv_empty);
+        tvSelfNumber = findViewById(R.id.tv_self_number);
+        tvCallRecord = findViewById(R.id.tv_call_order);
+        tvRecentSearch = findViewById(R.id.tv_recently_search);
+        TextView tvCancel = findViewById(R.id.tv_cancel);
+        tvCancel.setOnClickListener(v -> onBackPressed());
+        edtPhoneNumber.addTextChangedListener(new TextWatcher() {
+            @Override
+            public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+            }
+
+            @Override
+            public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+            }
+
+            @Override
+            public void afterTextChanged(Editable editable) {
+                if (!TextUtils.isEmpty(editable)) {
+                    ivClear.setVisibility(View.VISIBLE);
+                } else {
+                    ivClear.setVisibility(View.GONE);
+                }
+            }
+        });
+        ivClear.setOnClickListener(view -> edtPhoneNumber.setText(""));
+        rvRecentUser.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true));
+
+        rvSearchResult = findViewById(R.id.rv_search_result);
+        rvSearchResult.setLayoutManager(new LinearLayoutManager(this));
+        searchAdapter = new RecentUserAdapter(this);
+        rvSearchResult.setAdapter(searchAdapter);
+
+        callOrderAdapter = new CallOrderAdapter(this);
+        LinearLayoutManager callOrderLayoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true);
+        rvCallOrder.setLayoutManager(callOrderLayoutManager);
+        rvCallOrder.setAdapter(callOrderAdapter);
+        List<CallOrder> orderRecord = CallOrderManager.getInstance().getOrders();
+        callOrderAdapter.updateItem(orderRecord);
+        if (!orderRecord.isEmpty()) {
+            tvCallRecord.setVisibility(View.VISIBLE);
+        }
+
+    }
+
+    private void initData() {
+        UserModel currentUser = ProfileManager.getInstance().getUserModel();
+        tvSelfNumber.setText(String.format(getString(R.string.your_phone_number_is), currentUser.mobile));
+
+        CallOrderManager.getInstance().getOrdersLiveData().observe(this, new Observer<List<CallOrder>>() {
+            @Override
+            public void onChanged(List<CallOrder> orders) {
+                callOrderAdapter.updateItem(orders);
+                if (!orders.isEmpty()) {
+                    tvCallRecord.setVisibility(View.VISIBLE);
+                }
+            }
+        });
+
+        UserCacheManager.getInstance().getLastSearchUser(users -> runOnUiThread(() -> {
+            if (userAdapter == null) {
+                userAdapter = new RecentUserAdapter(NERTCSelectCallUserActivity.this);
+            }
+            rvRecentUser.setAdapter(userAdapter);
+            userAdapter.updateUsers(users);
+            if (users != null && !users.isEmpty()) {
+                tvRecentSearch.setVisibility(View.VISIBLE);
+            }
+        }));
+
+        btnSearch.setOnClickListener(v -> {
+            if (!NetworkUtils.isConnected()) {
+                Toast.makeText(NERTCSelectCallUserActivity.this, R.string.nertc_no_network, Toast.LENGTH_SHORT).show();
+                return;
+            }
+            String phoneNumber = edtPhoneNumber.getText().toString().trim();
+            if (!TextUtils.isEmpty(phoneNumber)) {
+                CallServiceManager.getInstance().searchUserWithPhoneNumber(phoneNumber, new BaseService.ResponseCallBack<UserModel>() {
+
+                    @Override
+                    public void onSuccess(UserModel response) {
+                        hideKeyBoard();
+                        if (response != null) {
+                            tvEmpty.setVisibility(View.GONE);
+                            rvSearchResult.setVisibility(View.VISIBLE);
+                            if (searchAdapter != null) {
+                                searchAdapter.updateItem(response);
+                            }
+                            UserCacheManager.getInstance().addUser(response);
+                        } else {
+                            ToastUtils.showLong(R.string.nertc_cant_find_this_user);
+                        }
+                    }
+
+                    @Override
+                    public void onFail(int code) {
+
+                    }
+                });
+            }
+        });
+    }
+
+    private void hideKeyBoard() {
+        View view = getCurrentFocus();
+        if (view != null) {
+            InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Activity.INPUT_METHOD_SERVICE);
+            inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
+        }
+    }
+}

+ 164 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/adapter/CallOrderAdapter.java

@@ -0,0 +1,164 @@
+package com.netease.yunxin.app.videocall.nertc.ui.adapter;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.blankj.utilcode.util.NetworkUtils;
+import com.blankj.utilcode.util.TimeUtils;
+import com.blankj.utilcode.util.ToastUtils;
+import com.bumptech.glide.Glide;
+import com.netease.nimlib.sdk.avsignalling.constant.ChannelType;
+import com.netease.nimlib.sdk.msg.attachment.NetCallAttachment;
+import com.netease.nimlib.sdk.msg.constant.MsgDirectionEnum;
+import com.netease.yunxin.app.videocall.R;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+import com.netease.yunxin.app.videocall.nertc.model.CallOrder;
+import com.netease.yunxin.app.videocall.nertc.utils.SelfTimeUtils;
+import com.netease.yunxin.nertc.nertcvideocall.utils.NrtcCallStatus;
+import com.netease.yunxin.nertc.ui.CallKitUI;
+import com.netease.yunxin.nertc.ui.base.CallParam;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 话单adapter
+ */
+public class CallOrderAdapter extends RecyclerView.Adapter<CallOrderAdapter.ViewHolder> {
+
+    public static final String TIME_FORMAT = "HH:mm:ss";
+
+    private final List<CallOrder> orders;
+
+    private final Context mContext;
+
+    //创建ViewHolder
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        public ImageView ivType;
+        public TextView tvNickname;
+        public TextView tvDuration;
+        public TextView tvTime;
+
+        public ViewHolder(View v) {
+            super(v);
+            ivType = v.findViewById(R.id.iv_type);
+            tvNickname = v.findViewById(R.id.tv_nickname);
+            tvDuration = v.findViewById(R.id.tv_duration);
+            tvTime = v.findViewById(R.id.tv_time);
+        }
+    }
+
+    public CallOrderAdapter(Context context) {
+        this.mContext = context;
+        orders = new ArrayList<>(3);
+    }
+
+    public void updateItem(List<CallOrder> orders) {
+        this.orders.clear();
+        this.orders.addAll(orders);
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public void onBindViewHolder(ViewHolder holder, int position) {
+        CallOrder order = orders.get(position);
+        UserModel currentUser = ProfileManager.getInstance().getUserModel();
+        if (order != null) {
+            holder.tvNickname.setText(order.nickname);
+            NetCallAttachment attachment = order.attachment;
+            if (attachment.getStatus() == NrtcCallStatus.NrtcCallStatusComplete) {
+
+                int durationSeconds = Integer.MAX_VALUE;
+                for (NetCallAttachment.Duration duration : attachment.getDurations()) {
+                    durationSeconds = Math.min(durationSeconds, duration.getDuration());
+                }
+                String textString = SelfTimeUtils.secToTime(durationSeconds);
+                holder.tvDuration.setText("\t" + textString);
+                holder.tvTime.setText(TimeUtils.millis2String(order.receivedTime - durationSeconds * 1000L, TIME_FORMAT));
+                holder.tvNickname.setTextColor(mContext.getResources().getColor(R.color.white));
+                holder.tvTime.setTextColor(mContext.getResources().getColor(R.color.white));
+                holder.tvDuration.setTextColor(mContext.getResources().getColor(R.color.white));
+                if (order.direction == MsgDirectionEnum.In) {
+                    if (attachment.getType() == ChannelType.AUDIO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.audio_in_normal).into(holder.ivType);
+                    } else if (attachment.getType() == ChannelType.VIDEO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.video_in_normal).into(holder.ivType);
+                    }
+                } else {
+                    if (attachment.getType() == ChannelType.AUDIO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.audio_out_normal).into(holder.ivType);
+                    } else if (attachment.getType() == ChannelType.VIDEO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.video_out_normal).into(holder.ivType);
+                    }
+                }
+            } else {
+                holder.tvTime.setText(TimeUtils.millis2String(order.receivedTime, TIME_FORMAT));
+                holder.tvNickname.setTextColor(mContext.getResources().getColor(R.color.red));
+                holder.tvTime.setTextColor(mContext.getResources().getColor(R.color.red));
+                holder.tvDuration.setText("");
+                if (order.direction == MsgDirectionEnum.In) {
+                    if (attachment.getType() == ChannelType.AUDIO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.audio_in_failed).into(holder.ivType);
+                    } else if (attachment.getType() == ChannelType.VIDEO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.video_in_failed).into(holder.ivType);
+                    }
+                } else {
+                    if (attachment.getType() == ChannelType.AUDIO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.audio_out_failed).into(holder.ivType);
+                    } else if (attachment.getType() == ChannelType.VIDEO.getValue()) {
+                        Glide.with(mContext).load(R.drawable.video_out_failed).into(holder.ivType);
+                    }
+                }
+            }
+
+            holder.itemView.setOnClickListener(view -> {
+                if (currentUser == null || TextUtils.isEmpty(currentUser.imAccid)) {
+                    Toast.makeText(mContext, "当前用户登录存在问题,请注销后重新登录", Toast.LENGTH_SHORT).show();
+                    return;
+                }
+                // 自定义透传字段,被叫用户在收到呼叫邀请时通过参数进行解析
+                JSONObject extraInfo = new JSONObject();
+
+                try {
+                    extraInfo.putOpt("key", "call");
+                    extraInfo.putOpt("value", "testValue");
+                    extraInfo.putOpt("userName", currentUser.mobile);
+                } catch (JSONException e) {
+                    e.printStackTrace();
+                }
+
+                if (NetworkUtils.isConnected()) {
+                    CallKitUI.startSingleCall(mContext,
+                            CallParam.createSingleCallParam(ChannelType.VIDEO.getValue(), currentUser.imAccid, order.sessionId, extraInfo.toString()));
+                } else {
+                    ToastUtils.showShort(R.string.network_connect_error_please_try_again);
+                }
+            });
+
+        }
+    }
+
+    @Override
+    public int getItemCount() {
+        return orders == null ? 0 : orders.size();
+    }
+
+
+    @Override
+    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.call_order_item_layout, parent, false);
+        return new ViewHolder(v);
+    }
+}

+ 120 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/ui/adapter/RecentUserAdapter.java

@@ -0,0 +1,120 @@
+package com.netease.yunxin.app.videocall.nertc.ui.adapter;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.blankj.utilcode.util.NetworkUtils;
+import com.blankj.utilcode.util.ToastUtils;
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
+import com.bumptech.glide.request.RequestOptions;
+import com.netease.nimlib.sdk.avsignalling.constant.ChannelType;
+import com.netease.yunxin.app.videocall.R;
+import com.netease.yunxin.app.videocall.login.model.ProfileManager;
+import com.netease.yunxin.app.videocall.login.model.UserModel;
+import com.netease.yunxin.nertc.ui.CallKitUI;
+import com.netease.yunxin.nertc.ui.base.CallParam;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class RecentUserAdapter extends RecyclerView.Adapter<RecentUserAdapter.ViewHolder> {
+
+    private final List<UserModel> mUsers = new ArrayList<>();
+
+    private final Context mContext;
+
+    //创建ViewHolder
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        public ImageView ivUser;
+        public TextView tvNickname;
+        public TextView tvCall;
+
+        public ViewHolder(View v) {
+            super(v);
+            ivUser = v.findViewById(R.id.iv_user);
+            tvNickname = v.findViewById(R.id.tv_nickname);
+            tvCall = v.findViewById(R.id.tv_call);
+        }
+    }
+
+    public RecentUserAdapter(Context context) {
+        this.mContext = context;
+    }
+
+    public void updateUsers(List<UserModel> users) {
+        if (users == null) {
+            return;
+        }
+        this.mUsers.clear();
+        mUsers.addAll(users);
+        notifyDataSetChanged();
+    }
+
+    public void updateItem(UserModel user) {
+        if (user == null) {
+            return;
+        }
+        this.mUsers.clear();
+        mUsers.add(user);
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public void onBindViewHolder(ViewHolder holder, int position) {
+        if (mUsers != null) {
+            holder.tvNickname.setText(mUsers.get(position).mobile);
+            Glide.with(mContext).load(mUsers.get(position).avatar).apply(RequestOptions.bitmapTransform(new RoundedCorners(7))).into(holder.ivUser);
+            holder.itemView.setOnClickListener(view -> {
+                UserModel currentUser = ProfileManager.getInstance().getUserModel();
+                if (currentUser == null || TextUtils.isEmpty(currentUser.imAccid)) {
+                    Toast.makeText(mContext, "当前用户登录存在问题,请注销后重新登录", Toast.LENGTH_SHORT).show();
+                    return;
+                }
+                UserModel searchedUser = mUsers.get(position);
+                if (currentUser.imAccid.equals(searchedUser.imAccid) || currentUser.mobile.equals(searchedUser.mobile)) {
+                    Toast.makeText(mContext, "不能呼叫自己!", Toast.LENGTH_SHORT).show();
+                    return;
+                }
+                if (NetworkUtils.isConnected()) {
+                    // 自定义透传字段,被叫用户在收到呼叫邀请时通过参数进行解析
+                    JSONObject extraInfo = new JSONObject();
+
+                    try {
+                        extraInfo.putOpt("key", "call");
+                        extraInfo.putOpt("value", "testValue");
+                        extraInfo.putOpt("userName", currentUser.mobile);
+                    } catch (JSONException e) {
+                        e.printStackTrace();
+                    }
+                    CallKitUI.startSingleCall(mContext,
+                            CallParam.createSingleCallParam(ChannelType.VIDEO.getValue(), currentUser.imAccid, searchedUser.imAccid, extraInfo.toString()));
+                } else {
+                    ToastUtils.showShort(R.string.network_connect_error_please_try_again);
+                }
+            });
+        }
+    }
+
+    @Override
+    public int getItemCount() {
+        return mUsers.size();
+    }
+
+    @Override
+    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.user_item_layout, parent, false);
+        return new ViewHolder(v);
+    }
+}

+ 35 - 0
app/src/main/java/com/netease/yunxin/app/videocall/nertc/utils/SelfTimeUtils.java

@@ -0,0 +1,35 @@
+package com.netease.yunxin.app.videocall.nertc.utils;
+
+public class SelfTimeUtils {
+    public static String unitFormat(int i) {
+        String retStr = null;
+        if (i >= 0 && i < 10)
+            retStr = "0" + i;
+        else retStr = "" + i;
+        return retStr;
+    }
+
+    public static String secToTime(int time) {
+        String timeStr = null;
+        int hour = 0;
+        int minute = 0;
+        int second = 0;
+        if (time <= 0)
+            return "00:00";
+        else {
+            minute = time / 60;
+            if (minute < 60) {
+                second = time % 60;
+                timeStr = unitFormat(minute) + ":" + unitFormat(second);
+            } else {
+                hour = minute / 60;
+                if (hour > 99)
+                    return "99:59:59";
+                minute = minute % 60;
+                second = time - hour * 3600 - minute * 60;
+                timeStr = unitFormat(hour) + ":" + unitFormat(minute) + ":" + unitFormat(second);
+            }
+        }
+        return timeStr;
+    }
+}

+ 30 - 0
app/src/main/res/drawable-v24/ic_launcher_foreground.xml

@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="85.84757"
+                android:endY="92.4963"
+                android:startX="42.9492"
+                android:startY="49.59793"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+        android:strokeWidth="1"
+        android:strokeColor="#00000000" />
+</vector>

BIN
app/src/main/res/drawable-xxhdpi/account_circle.png


BIN
app/src/main/res/drawable-xxhdpi/audio_in_failed.png


BIN
app/src/main/res/drawable-xxhdpi/audio_in_normal.png


BIN
app/src/main/res/drawable-xxhdpi/audio_out_failed.png


BIN
app/src/main/res/drawable-xxhdpi/audio_out_normal.png


BIN
app/src/main/res/drawable-xxhdpi/main_bg.png


BIN
app/src/main/res/drawable-xxhdpi/nim_icon_edit_delete.png


BIN
app/src/main/res/drawable-xxhdpi/seting.png


BIN
app/src/main/res/drawable-xxhdpi/video_call_icon.png


BIN
app/src/main/res/drawable-xxhdpi/video_in_failed.png


BIN
app/src/main/res/drawable-xxhdpi/video_in_normal.png


BIN
app/src/main/res/drawable-xxhdpi/video_out_failed.png


BIN
app/src/main/res/drawable-xxhdpi/video_out_normal.png


BIN
app/src/main/res/drawable-xxhdpi/yunxin_logo.png


+ 19 - 0
app/src/main/res/drawable/btn_call_bg.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp"
+    android:height="28dp"
+    android:viewportWidth="48"
+    android:viewportHeight="28">
+
+    <group>
+
+        <clip-path android:pathData="M2 0H46C47.1046 0 48 0.895431 48 2V26C48 27.1046 47.1046 28 46 28H2C0.895431 28 0 27.1046 0 26V2C0 0.895431 0.895431 0 2 0Z" />
+
+        <path
+            android:pathData="M2 0H46C47.1046 0 48 0.895431 48 2V26C48 27.1046 47.1046 28 46 28H2C0.895431 28 0 27.1046 0 26V2C0 0.895431 0.895431 0 2 0Z"
+            android:strokeWidth="2"
+            android:strokeColor="#FFFFFF" />
+
+    </group>
+
+</vector>

+ 8 - 0
app/src/main/res/drawable/btn_search_bg.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+
+    <corners android:radius="7dp" />
+
+    <solid android:color="#337EFF" />
+</shape>

+ 6 - 0
app/src/main/res/drawable/et_cursor.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <size android:width="1dp" />
+    <solid android:color="#C9CFE5" />
+</shape>

+ 19 - 0
app/src/main/res/drawable/et_login_code.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item android:state_window_focused="false">
+        <shape android:shape="rectangle">
+            <solid android:color="#FFFFFF" />
+            <stroke android:width="1dp" android:color="#C9CFE5" />
+            <corners android:radius="5dp" />
+        </shape>
+    </item>
+
+    <item android:state_focused="true">
+        <shape android:shape="rectangle">
+            <solid android:color="#ffffff" />
+            <stroke android:width="1dp" android:color="#337EFF" />
+            <corners android:radius="5dp" />
+        </shape>
+    </item>
+</selector>

+ 8 - 0
app/src/main/res/drawable/et_search_bg.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+
+    <corners android:radius="10dp" />
+
+    <solid android:color="#393945" />
+</shape>

+ 170 - 0
app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+</vector>

+ 8 - 0
app/src/main/res/drawable/item_call_bg.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+
+    <corners android:radius="20dp" />
+
+    <solid android:color="#CC272B46" />
+</shape>

+ 39 - 0
app/src/main/res/drawable/light_right_arrow.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item>
+        <shape>
+            <solid android:color="@android:color/transparent" />
+            <size
+                android:width="2dp"
+                android:height="50dp" />
+        </shape>
+    </item>
+
+    <item android:bottom="20dp">
+        <rotate
+            android:fromDegrees="-45"
+            android:toDegrees="45">
+            <shape android:shape="rectangle">
+                <solid android:color="#FFFFFF" />
+                <corners
+                    android:radius="1dp"
+                    android:bottomRightRadius="0dp"
+                    android:bottomLeftRadius="0dp" />
+            </shape>
+        </rotate>
+    </item>
+
+    <item android:top="20dp">
+        <rotate
+            android:fromDegrees="45"
+            android:toDegrees="45">
+            <shape android:shape="rectangle">
+                <solid android:color="#FFFFFF" />
+                <corners
+                    android:radius="1dp"
+                    android:topRightRadius="0dp"
+                    android:topLeftRadius="0dp" />
+            </shape>
+        </rotate>
+    </item>
+</layer-list>

+ 18 - 0
app/src/main/res/drawable/login_button_border.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_enabled="false">
+        <shape android:shape="rectangle" android:useLevel="false">
+            <solid android:color="@color/login_color_btn_disable" />
+            <corners android:radius="30dp" />
+        </shape>
+    </item>
+
+    <item android:state_enabled="true">
+        <shape android:shape="rectangle" android:useLevel="false">
+            <solid android:color="@color/login_color_btn_enable" />
+            <corners android:radius="30dp" />
+        </shape>
+    </item>
+</selector>
+

+ 5 - 0
app/src/main/res/drawable/login_button_text_selector.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:color="#26000000" android:state_enabled="false" />
+    <item android:color="@android:color/white" />
+</selector>

+ 7 - 0
app/src/main/res/drawable/video_call_bg.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <gradient
+        android:angle="135"
+        android:endColor="#1E1E25"
+        android:startColor="#292933" />
+</shape>

+ 107 - 0
app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@drawable/main_bg"
+    tools:context=".MainActivity">
+
+    <ImageView
+        android:id="@+id/iv_top_logo"
+        android:layout_width="228dp"
+        android:layout_height="28dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        android:layout_marginTop="30dp"
+        android:layout_marginStart="20dp"
+        android:background="@drawable/yunxin_logo" />
+
+
+    <ImageView
+        android:id="@+id/iv_account"
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        android:layout_marginEnd="20dp"
+        android:layout_marginTop="30dp"
+        android:src="@drawable/account_circle" />
+
+    <RelativeLayout
+        android:id="@+id/rly_video_call"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toBottomOf="@+id/iv_top_logo"
+        android:layout_margin="20dp"
+        android:padding="20dp"
+        android:background="@drawable/item_call_bg"
+        app:layout_constraintLeft_toLeftOf="parent">
+
+        <ImageView
+            android:id="@+id/iv_call"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            android:src="@drawable/video_call_icon" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_toRightOf="@+id/iv_call"
+            android:layout_centerVertical="true"
+            android:layout_marginStart="14dp"
+            android:text="@string/video_call"
+            android:textColor="@color/colorWhite"
+            android:textSize="18sp" />
+
+        <ImageView
+            android:layout_width="20dp"
+            android:layout_height="20dp"
+            android:layout_alignParentEnd="true"
+            android:textColor="@color/colorWhite"
+            android:layout_centerVertical="true"
+            android:src="@drawable/light_right_arrow" />
+    </RelativeLayout>
+
+    <TextView
+        android:id="@+id/tv_version"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toTopOf="@+id/tv_comment"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        android:textColor="@color/colorWhite"
+        android:gravity="center_horizontal"
+        android:text="version"
+        android:layout_marginBottom="15dp" />
+
+    <TextView
+        android:id="@+id/tv_comment"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        android:textColor="@color/colorWhite"
+        android:text="本APP仅用于展示网易云信实时音视频各类功能"
+        android:layout_marginBottom="50dp" />
+
+    <Button
+        android:id="@+id/btn"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        android:textSize="10dp"
+        android:layout_marginBottom="200dp"
+        android:text="开始dump,建议3分钟后点结束dump按钮"
+        android:layout_width="match_parent"
+        android:layout_height="44dp" />
+    <Button
+        android:id="@+id/btn2"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        android:textSize="10dp"
+        android:text="结束dump,请到/sdcard/Android/data/com.netease.videocall.demo/files/dump目录查看dump文件"
+        android:layout_marginBottom="150dp"
+        android:layout_width="match_parent"
+        android:layout_height="44dp" />
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 41 - 0
app/src/main/res/layout/call_order_item_layout.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="10dp">
+
+    <ImageView
+        android:id="@+id/iv_type"
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        android:scaleType="fitXY" />
+
+    <TextView
+        android:id="@+id/tv_nickname"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toEndOf="@+id/iv_type"
+        android:textSize="14sp"
+        android:textColor="@color/colorWhite"
+        android:layout_marginStart="10dp"
+        android:layout_centerVertical="true" />
+
+    <TextView
+        android:id="@+id/tv_time"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerVertical="true"
+        android:textSize="12sp"
+        android:alpha="0.5"
+        android:layout_toStartOf="@+id/tv_duration" />
+
+    <TextView
+        android:id="@+id/tv_duration"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerVertical="true"
+        android:layout_alignParentEnd="true"
+        android:alpha="0.5"
+        android:textSize="12sp" />
+
+</RelativeLayout>

+ 102 - 0
app/src/main/res/layout/login_activity.xml

@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="20dp"
+    android:background="@color/colorActivityBackground"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <TextView
+        android:id="@+id/tv_welcome"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        android:textColor="#FF222222"
+        android:textSize="28sp"
+        android:text="你好,欢迎登录" />
+
+    <LinearLayout
+        android:id="@+id/lly_phone_number"
+        android:layout_width="match_parent"
+        android:layout_height="30dp"
+        app:layout_constraintTop_toBottomOf="@+id/tv_welcome"
+        app:layout_constraintLeft_toLeftOf="parent"
+        android:layout_marginTop="50dp"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textColor="#FF333333"
+            android:textSize="15sp"
+            android:text="+86" />
+
+        <View
+            android:layout_width="1dp"
+            android:layout_height="match_parent"
+            android:layout_marginStart="5dp"
+            android:layout_marginEnd="5dp"
+            android:background="#FFDCDFE5" />
+
+        <EditText
+            android:id="@+id/edt_phone_number"
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:inputType="number"
+            android:maxLength="11"
+            android:textColor="#FF333333"
+            android:textSize="15sp"
+            android:hint="请输入手机号"
+            android:textColorHint="#FFB0B6BE"
+            android:background="@null" />
+    </LinearLayout>
+
+
+    <View
+        android:id="@+id/divider_view"
+        android:layout_width="match_parent"
+        android:layout_height="1dp"
+        android:background="#FFDCDFE5"
+        android:layout_marginTop="10dp"
+        android:layout_marginBottom="10dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/lly_phone_number" />
+
+    <TextView
+        android:id="@+id/tv_login_comment"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textColor="#FFB0B6BE"
+        android:textSize="12sp"
+        android:text="未注册的手机号验证通过后将自动注册"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/divider_view" />
+
+    <Button
+        android:id="@+id/btn_send"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/tv_login_comment"
+        android:background="@drawable/login_button_border"
+        android:text="获取验证码"
+        android:textColor="@drawable/login_button_text_selector"
+        android:enabled="true"
+        android:textSize="14sp"
+        android:layout_marginTop="50dp"
+        android:layout_marginStart="10dp"
+        android:layout_marginEnd="10dp" />
+
+    <TextView
+        android:id="@+id/tv_declare"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        android:layout_marginBottom="50dp" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 30 - 0
app/src/main/res/layout/user_item_layout.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="8dp">
+
+    <ImageView
+        android:id="@+id/iv_user"
+        android:layout_width="40dp"
+        android:layout_height="40dp" />
+
+    <TextView
+        android:id="@+id/tv_nickname"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toEndOf="@+id/iv_user"
+        android:layout_marginStart="7dp"
+        android:layout_centerVertical="true" />
+
+    <TextView
+        android:id="@+id/tv_call"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerVertical="true"
+        android:layout_alignParentEnd="true"
+        android:gravity="center"
+        android:text="@string/call"
+        android:background="@drawable/btn_call_bg" />
+
+</RelativeLayout>

+ 86 - 0
app/src/main/res/layout/verify_code_layout.xml

@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:background="@color/colorActivityBackground"
+    android:orientation="vertical">
+
+
+    <ImageView
+        android:id="@+id/iv_back"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="请输入验证码"
+        android:layout_marginStart="30dp"
+        android:layout_marginTop="20dp"
+        android:textColor="#FF222222"
+        android:textSize="28sp" />
+
+    <TextView
+        android:id="@+id/tv_msm_comment"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="15dp"
+        android:layout_marginStart="30dp"
+        android:layout_marginEnd="30dp"
+        android:layout_marginBottom="20dp"
+        android:textColor="#FF333333"
+        android:textSize="15sp" />
+
+    <com.netease.yunxin.app.videocall.login.ui.view.VerifyCodeView
+        android:id="@+id/vcv_sms"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="center"
+        android:orientation="horizontal"
+        app:vcv_et_bg="@drawable/et_login_code"
+        app:vcv_et_cursor="@drawable/et_cursor"
+        app:vcv_et_inputType="number"
+        app:vcv_et_cursor_visible="false"
+        app:vcv_et_number="4"
+        app:vcv_et_text_color="@android:color/black"
+        app:vcv_et_text_size="12sp" />
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center_horizontal"
+        android:layout_marginTop="20dp"
+        android:orientation="horizontal">
+
+        <TextView
+            android:id="@+id/tv_time_discount"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textColor="#FF2953FF"
+            android:textSize="12sp" />
+
+        <TextView
+            android:id="@+id/tv_resend_msm"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textColor="#FF333333"
+            android:text=" 后重新发送验证码"
+            android:textSize="12sp" />
+    </LinearLayout>
+
+    <Button
+        android:id="@+id/btn_next"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center_horizontal"
+        android:background="@drawable/login_button_border"
+        android:textColor="@color/colorWhite"
+        android:textSize="14sp"
+        android:layout_marginTop="40dp"
+        android:layout_marginStart="30dp"
+        android:layout_marginEnd="30dp"
+        android:text="下一步" />
+
+
+</LinearLayout>

+ 160 - 0
app/src/main/res/layout/video_call_select_layout.xml

@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@drawable/video_call_bg"
+    android:orientation="vertical">
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="50dp"
+        android:padding="15dp">
+
+        <TextView
+            android:id="@+id/tv_cancel"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textColor="@color/colorWhite"
+            android:textSize="16dp"
+            android:text="@string/cancel"
+            tools:ignore="SpUsage" />
+
+        <TextView
+            android:id="@+id/tv_title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_centerHorizontal="true"
+            android:text="@string/start_call"
+            android:textColor="@color/colorWhite"
+            android:textSize="17dp"
+            tools:ignore="SpUsage" />
+
+        <ImageView
+            android:id="@+id/iv_setting"
+            android:layout_width="20dp"
+            android:layout_height="match_parent"
+            android:src="@drawable/seting"
+            android:layout_alignParentEnd="true" />
+    </RelativeLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@drawable/et_search_bg"
+        android:layout_margin="20dp"
+        android:orientation="horizontal">
+
+        <EditText
+            android:id="@+id/edt_phone_number"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:hint="@string/please_input_the_number_have_register"
+            android:layout_gravity="center_vertical"
+            android:layout_marginStart="10dp"
+            android:textColorHint="#80FFFFFF"
+            android:inputType="number"
+            android:textSize="16sp"
+            android:maxLength="11"
+            android:background="@null"
+            android:layout_weight="1" />
+
+        <ImageView
+            android:id="@+id/iv_clear"
+            android:layout_width="16dp"
+            android:layout_height="16dp"
+            android:layout_marginStart="10dp"
+            android:layout_marginEnd="10dp"
+            android:layout_gravity="center_vertical"
+            android:visibility="gone"
+            android:src="@drawable/nim_icon_edit_delete" />
+
+        <Button
+            android:id="@+id/btn_search"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:background="@drawable/btn_search_bg"
+            android:layout_margin="7dp"
+            android:textColor="@color/colorWhite"
+            android:textSize="12sp"
+            android:text="@string/search" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/tv_self_number"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textColor="@color/white"
+        android:alpha="0.5"
+        android:textSize="16sp"
+        android:layout_marginStart="20dp" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="15dp"
+        android:layout_marginStart="20dp"
+        android:textColor="@color/colorWhite"
+        android:textSize="14sp"
+        android:text="@string/search_result" />
+
+    <FrameLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="8dp">
+
+        <TextView
+            android:id="@+id/tv_empty"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textColor="#CCCCCC"
+            android:layout_marginStart="12dp"
+            android:alpha="0.5"
+            android:text="@string/empty" />
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/rv_search_result"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="12dp"
+            android:layout_marginEnd="12dp"
+            android:visibility="gone" />
+    </FrameLayout>
+
+    <TextView
+        android:id="@+id/tv_recently_search"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:layout_marginStart="20dp"
+        android:textColor="@color/colorWhite"
+        android:textSize="14sp"
+        android:visibility="gone"
+        android:text="@string/recently_search" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/rv_recent_user"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="12dp"
+        android:layout_marginEnd="12dp" />
+
+    <TextView
+        android:id="@+id/tv_call_order"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:layout_marginStart="20dp"
+        android:textColor="@color/colorWhite"
+        android:textSize="14sp"
+        android:visibility="gone"
+        android:text="@string/call_record" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/rv_call_order"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="10dp"
+        android:layout_marginEnd="10dp" />
+
+</LinearLayout>

BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png


+ 30 - 0
app/src/main/res/values/attrs.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- 自定义验证码输入框-->
+    <declare-styleable name="vericationCodeView">
+        <!--输入框的数量-->
+        <attr name="vcv_et_number" format="integer" />
+        <!--输入类型-->
+        <attr name="vcv_et_inputType">
+            <enum name="number" value="0" />
+            <enum name="numberPassword" value="1" />
+            <enum name="text" value="2" />
+            <enum name="textPassword" value="3" />
+        </attr>
+        <!--输入框的宽度-->
+        <attr name="vcv_et_width" format="dimension|reference" />
+        <!--输入框文字颜色-->
+        <attr name="vcv_et_text_color" format="color|reference" />
+        <!--输入框文字大小-->
+        <attr name="vcv_et_text_size" format="dimension|reference" />
+        <!--输入框背景-->
+        <attr name="vcv_et_bg" format="reference" />
+        <!--光标样式-->
+        <attr name="vcv_et_cursor" format="reference" />
+        <!--是否隐藏光标-->
+        <attr name="vcv_et_cursor_visible" format="boolean" />
+        <!--输入框间距,不输入则代表平分-->
+        <attr name="vcv_et_spacing" format="dimension|reference" />
+
+    </declare-styleable>
+</resources>

+ 11 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="colorPrimary">#6200EE</color>
+    <color name="colorPrimaryDark">#3700B3</color>
+    <color name="colorAccent">#03DAC5</color>
+    <color name="colorActivityBackground">#ffffff</color>
+    <color name="colorWhite">#ffffff</color>
+    <color name="login_color_btn_enable">#FF0062E3</color>
+    <color name="login_color_btn_disable">#F2F2F2</color>
+    <color name="red">#FF5D54</color>
+</resources>

+ 34 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,34 @@
+<resources>
+    <string name="app_name">VideoCall</string>
+    <string name="video_call">一对一音视频通话</string>
+    <string name="login_btn_login">登录</string>
+    <string name="login_phone_number_cant_less_than_eleven">手机号不能少于11位</string>
+    <string name="login_phone_number_cant_null">手机号不能为空</string>
+    <string name="login_sms_code_has_been_sent">验证码已经发送至 +86-</string>
+    <string name="login_please_input_sms_code">,请在下方输入验证码</string>
+    <string name="login_please_input_correct_sms_code">请输入正确的验证码</string>
+    <string name="sixty_second">60s</string>
+    <string name="login_resend">重新发送</string>
+    <string name="login_second">s</string>
+    <string name="login_success">登录成功</string>
+    <string name="login_fail">登录失败</string>
+    <string name="login_sms_code_send_success">验证码重新发送成功</string>
+    <string name="login_sms_code_send_fail">验证码重新发送失败</string>
+    <string name="nertc_cant_find_this_user">未找到此用户</string>
+    <string name="nertc_no_network">网络未连接</string>
+    <string name="nertc_other_cancel">对方已取消</string>
+    <string name="nertc_other_refused">对方已拒绝</string>
+    <string name="nertc_other_line_busy">对方占线</string>
+    <string name="nertc_call_is_answer_on_other_device">通话已在其它设备接听</string>
+    <string name="call">呼叫</string>
+    <string name="empty">无</string>
+    <string name="your_phone_number_is">您的手机号:%s</string>
+    <string name="recently_search">最近搜索</string>
+    <string name="call_record">通话记录</string>
+    <string name="search_result">搜索结果</string>
+    <string name="cancel">取消</string>
+    <string name="start_call">发起呼叫</string>
+    <string name="please_input_the_number_have_register">输入手机号搜索已注册用户</string>
+    <string name="search">搜索</string>
+    <string name="network_connect_error_please_try_again">网络异常,请稍后再试</string>
+</resources>

+ 10 - 0
app/src/main/res/values/styles.xml

@@ -0,0 +1,10 @@
+<resources>
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+</resources>

+ 4 - 0
app/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+    <base-config cleartextTrafficPermitted="true" />
+</network-security-config>

+ 61 - 0
build.gradle

@@ -0,0 +1,61 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+    repositories {
+        google()
+        maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
+        jcenter()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:4.0.1'
+
+        // NOTE: Do not place your application dependencies here; they belong
+        // in the individual module build.gradle files
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+        maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
+        maven { url 'http://developer.huawei.com/repo/' }
+        maven { url "https://jitpack.io" }
+        jcenter()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
+
+ext {
+    compileSdkVersion = 31
+    buildToolsVersion = "30.0.0"
+    minSdkVersion = 21
+    targetSdkVersion = 30
+
+    ndkAbis = [
+            'armeabi-v7a',
+            'x86',
+            'arm64-v8a',
+            'x86_64'
+    ]
+    AppKey = ''
+    BaseUrl = ''
+}
+def loadLocalConfig() {
+    String env = System.getProperty("env", "test")
+    if (env != "test") env = "online"
+    println "env=$env"
+    def propertiesFile = file("config/${env}.properties")
+    if (!propertiesFile.exists()) {
+        println "Local properties don't exist."
+        return
+    }
+    Properties config = new Properties()
+    config.load(propertiesFile.newInputStream())
+    this.AppKey = config.getProperty('APP_KEY')
+    this.BaseUrl = config.getProperty('BASE_URL')
+}
+
+loadLocalConfig()

+ 23 - 0
gradle.properties

@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+android.injected.testOnly=false
+
+VERSION_CODE=2
+VERSION_NAME=1.4.2

BIN
gradle/wrapper/gradle-wrapper.jar


+ 6 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,6 @@
+#Wed Aug 19 16:29:12 CST 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip

+ 172 - 0
gradlew

@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+  cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"

+ 84 - 0
gradlew.bat

@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 2 - 0
settings.gradle

@@ -0,0 +1,2 @@
+include ':app'
+rootProject.name = "NERTCScenesDemo"

+ 38 - 0
workflow/config_stub.py

@@ -0,0 +1,38 @@
+import re
+import sys
+import os
+
+APP_CONFIG_FILE = sys.argv[1]
+APP_KEY = sys.argv[2]
+BASE_URL = sys.argv[3]
+
+
+# Stub a string with a evironment variable
+def stubString(searchPattern, rejectPattern, searchtText, replacement, desc):
+	searchRet = re.search(searchPattern, searchtText).group()
+	if re.search("\"{}\"".format(rejectPattern), searchRet) is not None:
+		print
+		"A valid {} is submitted!".format(desc)
+		sys.exit(1)
+
+	# Stub valid key and write back
+	ret = re.sub('\"\S+\"', "\"{}\"".format(replacement), searchRet)
+	ret = re.sub(searchPattern, ret, searchtText)
+	return ret
+
+
+# Read heaer file
+
+io = open(APP_CONFIG_FILE, "r+")
+text = io.read()
+
+ret = stubString("std::string\sappKey\s?=\s?\"\S*\";", "^[a-f0-9]{32}$", text, APP_KEY, 'APP_KEY')
+ret = stubString("std::string\sbaseURL\s?=\s?\"\S*\";",
+				 "(?:rtmp:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+", ret,
+				 BASE_URL, 'BASE_URL')
+
+io.seek(0)
+io.write(ret)
+io.truncate()
+
+io.close()

BIN
workflow/release.jks.gpg