# java-客户端接入指南

---

本文档面向 Android 开发者，描述的接入方式适合运营在 4399 平台的独代游戏。

## SDK 下载

SDK和相关示例的下载地址可在游戏创建后，进入 **「游戏管理-SDK服务」** 页面获取。

<img src="https://f.abd001.com//common/show?f=cm2741857&s=1bed1769b126f33300" alt="image" width="auto" height="auto">

## SDK 说明

### 用户信息与隐私策略

<div class="v-md-plugin-tip warning">
    <p class="v-md-plugin-tip-title">注意</p>
    <p>如果您是开发者，在为用户提供服务前请阅读<a href="https://ptlogin.4399.com/resource/protocol.html?type=1&aids=1,3,6,7" target="_blank"> 《4399通行证用户服务协议》</a>与<a href="https://ptlogin.4399.com/resource/protocol.html?type=2&aids=4,10" target="_blank">《隐私政策》</a>，了解SDK对个人信息收集范围、处理目的以及权限使用情况。请您向用户提供服务时，告知相关信息并取得用户同意。</p>
    <p>如果您是用户，请在使用我们的服务前阅读<a href="https://ptlogin.4399.com/resource/protocol.html?type=1&aids=1,3,6,7" target="_blank"> 《4399通行证用户服务协议》</a>与<a href="https://ptlogin.4399.com/resource/protocol.html?type=2&aids=4,10" target="_blank">《隐私政策》</a>，了解SDK对个人信息收集范围、处理目的以及权限使用情况。充分理解后再开始使用我们的服务。</p>
</div>

### 功能描述

为4399独家代理的游戏提供的SDK，提供游戏激活、礼包、更新等服务，和游戏官方消息官方活动、论坛等丰富而优质的游戏内容

若游戏有被静态修改后二次打包的情况，SDK 对此类破解提供了一些解决方案，详见 [服务端文档](https://sdkftp.4399doc.com/external/operate/3.19/server_api_2.html)

### SDK 组成

#### 客户端 SDK

SDK 优先提供在线`aar`依赖方式，Demo 结构遵循 Android Studio（as） 规范，但仍然保留了 `jar+res` 的依赖方式。  
SDK 支持的编译配置 **android:minSdkVersion >= 21**。

#### 服务端 API
服务端支持额外的功能，参考 [SDK 服务端接入文档](https://sdkftp.4399doc.com/external/operate/3.19/server_api_2.html)

## 集成流程
### 准备

首次接入 SDK，要在 [4399 开放平台](https://open.4399.cn/) 注册应用，主要是提交APK、素材等信息。   
完成后，开发者将得到 SDK 的基础参数：`game key`或`GameKey`，游戏在 4399 平台的运营标识（开发者平台的游戏信息页面显示为“游戏ID”）

### 引入依赖  
根据游戏需要，以下三种方式可选其一

- *在线aar*  
若使用*在线aar*，开发者只要在使用 SDK 的 module 的 `build.gradle`中引入以下内容即可  

```groovy
repositories {
    maven {
        // 4399 SDK 开放仓库：正式
        url 'https://mvn.4399doc.com/repository/maven-releases'
    }
    maven {
        // 4399 SDK 开放仓库：快照
        url 'https://mvn.4399doc.com/repository/maven-snapshots'
    }
}

dependencies {
  implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])

  // 运营 SDK：建议使用最新正式版本（release仓库中的版本），可通过浏览仓库地址或向运营咨询版本
  implementation "cn.m4399.sdk:operate:3.19.0"

  // volley、support v4、okhttp3、kotlin 是 SDK 使用的外部依赖，若接入方已有，可忽略
  implementation 'com.android.volley:volley:1.2.1'
  //noinspection GradleCompatible
  implementation "com.android.support:support-v4:28.0.0"
  implementation 'com.squareup.okhttp3:okhttp:3.12.8'
  implementation 'com.squareup.okio:okio:1.15.0'
  implementation 'com.squareup.okhttp3:logging-interceptor:3.12.8'
  // kotlin 库支持 1.6.0 以上版本
  implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0'
  implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.2.0'
  implementation 'org.jetbrains.kotlin:kotlin-android-extensions-runtime:2.2.0'
}
```

*注意：若使用`7.0+`版本的 gradle 及 android build 插件，仓库地址应配置在`settings.gradle`中*

- *本地aar*  
  若使用*本地aar*，则要下载 [在线aar](https://mvn.4399doc.com/repository/maven-releases/cn/m4399/sdk/operate/3.19.0/operate-3.19.0.aar) 和 [jar+res](https://sdkftp.4399doc.com/external/operate/3.19/operate-3.19.0+722.zip) 到本地，并将`aar`整体和`jar`包中的以下文件  
  拷贝到游戏模块的`libs`目录下（若游戏已有类似版本的外部依赖，可忽略）

```
operate/libs/volley-v1.2.1.jar
operate/libs/support-v13-23.2.1.jar
operate/libs/otlin-android-extensions-runtime-2.2.0.jar  
operate/libs/kotlin-stdlib-2.2.0.jar  
operate/libs/kotlin-stdlib-jdk7-2.2.0.jar  
operate/libs/logging-interceptor-3.12.8.jar  
operate/libs/okhttp-3.12.8.jar  
operate/libs/okio-1.15.0.jar
```

Gradle 文件中注意添加本地依赖
```groovy
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar','*.aar'])
}
```

- *jar+res*  
  [jar+res 依赖引入](https://sdkftp.4399doc.com/external/operate/3.19/jar_res_import.html) 比较繁琐，但是完成后，后续接口调用仍然是一样的。

#### abi 适配与 so 库
若游戏不需要支持所有 [abi](https://developer.android.google.cn/ndk/guides/abis?hl=zh-cn) ，可以按需选用。  
`aar`依赖方式，按以下方式配置需要的`abi`；`jar+res`依赖方式需要手动删除**不需要**的`abi`目录
```
android {
    defaultConfig {
        ndk {
            // 根据游戏需要选择
            abiFilters "armeabi-v7a", "x86", "arm64-v8a"
        }
    }
}
```

#### AndroidManifest 额外配置

在游戏项目的`AndroidManifest.xml`中，需要<span style="color:red">**注册存储权限和渠道标识**</span>

```xml
<!-- 接入方应在调用接口前，申请好运行时权限 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<application ....><!-- 渠道标记，4399官方渠道为‘4399’，其他渠道可与运营确认 -->
<meta-data android:name="FTNN_CHANNEL_ID" android:value="4399" /></application>
```

接入方应在**调用接口前，申请好运行时权限**，可以使用[EasyPemissions](https://github.com/googlesamples/easypermissions)
第三方类库快速处理

### 接口调用：游戏用户协议弹窗

**功能简介：** 游戏调用此接口后，弹出隐私弹窗，用户同意可以调用后续初始化，否则 SDK 将自动退出游戏。  
**调用时机：** 游戏启动即可调用，但要在进入游戏前

游戏调用此接口前，需要将弹窗文案和隐私地址提交给运营配置。代码示例参考下面“初始化”接口。  

### 接口调用：初始化（必须）

**功能简介：** 此接口初始化 SDK 内部状态，并完成“更新、激活、防沉迷”等流程。<span style="color:red">**SDK 初始化后才能正确调用其他接口**</span>   
**调用时机：** 建议在游戏 `Activity` 的开始处如 `onCreate` 方法中进行

```java
private void initSDK() {
    // 'this' 是游戏 Activity 实例
    final Activity activity = this;
    // 初始化 SDK
    // 配置全局属性，如横竖屏配置
    OperateConfig operateConfig = new OperateConfig.Builder(activity)
            // 设置调试模式，可选，true时打开，默认false,发布前必须设置为false或删除该行
            .setDebugEnabled(false)
            // 设置游戏运营 key，此参数需要在原创开放平台注册应用后得到
            .setGameKey(GAME_KEY)
            // 设置SDK页面方向，应与游戏方向一致，部分第三方页面需要在AndroidManifest中设置
            .setOrientation(GAME_ORIENTATION)
            // 设置游戏是否兼在高于Android 9.0版本系统容全面屏，true兼容，默认false
            .compatNotch(GAME_COMPACT_NOTCH)
            .build();
    OperateCenter.getInstance().gameProtocol(activity, operateConfig, new OpeResultListener() {
        @Override
        public void onResult(int code, @Nullable String message) {
            // 其中code：0、用户同意协议；160001、没有协议更新；用户不同意，则SDK结束进程，退出游戏
            // 同意后再初始化其他部分，如初始化 4399 SDK
            if (code == 0 || code == 160001) {
              // 用户同意协议
              afterGameProtocol(activity, operateConfig);
            }else{
              // 用户不同意协议，此处建议退出游戏进程
            }
        }
    });
}
```
`afterGameProtocol(activity, operateConfig);`有以下两种实现方式，分别代表使用4399联运sdk的初始化和4399独代sdk初始化，  
两种方式主要区别在于：4399联运sdk初始化回调接口包含初始化成功和账号登出以及切换账号等回调，4399独代sdk仅有初始化完成的回调

**4399联运sdk初始化：**

```java
private void afterGameProtocol(Activity activity, OperateConfig operateConfig) {
    // 4399联运sdk初始化
    OperateCenter.getInstance().init(activity, operateConfig, new OperateCenter.OnInitGlobalListener() {
        @Override
        public void onInitFinished(boolean isLogin, User user) {
                // 4399联运sdk初始化完成
                // isLogin ： 是否已登录账号 ； user ： 账号信息
        }

        @Override
        public void onUserAccountLogout(boolean fromUserCenter) {
                // 退出账号回调
                // fromUserCenter ：是否从用户中心点击退出 ；
        }

        @Override
        public void onSwitchUserAccountFinished(boolean fromUserCenter, User user) {
                // 切换账号回调
                // fromUserCenter ：是否从用户中心点击切换账号 ；
        }
    });
}

// 登录
public void login() {
    OperateCenter.getInstance().login(requireActivity(), (success, resultCode, user) -> {
        // success : 是否登录成功
        // resultCode : 登录状态码
        // user : 账号信息
    });
}

// 切换账号
public void switchAccount() {
    // 该句调用后会触发初始化回调中的onSwitchUserAccountFinished
    OperateCenter.getInstance().switchAccount(requireActivity(), (success, resultCode, user) -> {
        // success : 是否登录成功
        // resultCode : 登录状态码
        // user : 账号信息
    });
}

// 退出账号
public void logout() {
    // 该句调用后会触发初始化回调中的onUserAccountLogout
    OperateCenter.getInstance().logout();
}
```

注意，4399联运sdk的悬浮球菜单中有切换账号和退出账号的按钮，点击后引起的账号改变在初始化回调中通知

**4399独代sdk初始化：**

```java
private void afterGameProtocol(Activity activity, OperateConfig operateConfig) {
    // 4399独代sdk初始化
    ExclusiveAgent.init(activity, operateConfig, new ExclusiveAgent.OnInitGlobalListener() {
        /*
         * 初始化完成，玩家在此处可以安全进入游戏
         */
        @Override
        public void onInitFinished() {
        }
    });
}
```

初始化的几点说明
#### 页面方向

- *游戏页面* 应如下配置`android:configChanges`等，否则系统强制方向变化（一般发生是打开外部应用，如支付宝、微信）或系统设置变化时，可能会发生异常

```xml
<!-- 游戏 Activity：
     若覆盖了 android:resizeableActivity="false"，则必须如下设置 android:configChanges
     android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|navigation|smallestScreenSize|fontScale"
     
     不应该使用 android:launchMode="singleTask" 启动模式，可以考虑 singleTop
 -->
```

其中`android:configChanges` 新增的配置项，是为适配 Android 分屏模式而添加的，更多内容参考[多窗口支持](https://developer.android.google.cn/guide/topics/ui/multi-window?hl=zh-cn#config)


**分屏（画中画）兼容**：游戏进入分屏再点击桌面图标启动，会重启游戏页面，而并没有完整的启动应用，这会导致部分功能异常，  
为了兼容这种情况，SDK 为应用默认设置了`android:resizeableActivity="false"`，即不支持分屏模式，  
<span style="color:red;font-weight:700">若游戏出于某些原因必须要覆盖此项，则务必按最低标准设置入口 Activity 和主 Activity的`android:configChanges`</span>，即
```xml
<activity
    android:name=".MainActivity"
    android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|navigation|smallestScreenSize|fontScale"
/>
```

- *SDK 页面*，**方向支持横屏或竖屏，但不支持横竖屏切换**  
  通过`OperateConfig.setOrientation()`设置 SDK 页面方向，值是系统类`android.content.pm.ActivityInfo`定义的常量

| 方向参数| 含义|
|----------------------------------------|---------|
| `SCREEN_ORIENTATION_LANDSCAPE`，`int`, 0 | 横屏|
| `SCREEN_ORIENTATION_PORTRAIT`, `int`, 1 | 竖屏 |
| `SCREEN_ORIENTATION_SENSOR_LANDSCAPE`，`int`, 6 | 横屏，可180度旋转 |
| `SCREEN_ORIENTATION_SENSOR_PORTRAIT`，`int`, 7 | 竖屏 |

- *第三方页面*，其方向一般使用 SDK 默认设置即可，需要锁定可设置`android:screenOrientation`，如下所示

```xml
<!-- 
    4399 SDK：SDK 中除了以'cn.m4399.'开头的 Activity 都是第三方页面，
        假设游戏为横屏，需要锁定它们的方向为横屏，可以设置 android:screenOrientation，
        此设置应与初始化接口中设置的值保持对应，且若编译有错误，可以进一步设置‘tools:replace’
 -->
<activity android:name="com.cmic.gen.sdk.view.GenLoginAuthActivity"
    android:configChanges="orientation|keyboardHidden|screenSize" 
    android:launchMode="singleTop"
    android:screenOrientation="landscape" 
    android:theme="@style/AppTheme.NoActionBar"
    tools:replace="android:theme, android:screenOrientation" />
```

### 接口调用：游戏内容相关（推荐）

初始化之后，可以调用游戏盒跳转接口，这些接口可以大大丰富游戏内容、补充游戏功能。

#### 使用活动码
**功能简介：** 此接口用于校验玩家在游戏盒中领取的活动码，校验成功后玩家获得活动资格或活动奖励。  
**调用时机：** 初始化且回调后

```java
/*
 使用活动码
 your_activity_id: 活动ID，向运营获取
 your_activity_code: 活动码，用户输入
*/
ExclusiveAgent.useActivityCode("your_activity_id", 
    "your_activity_code", 
    new OpeResultListener() {
    @Override
    public void onResult(int code, @Nullable String message) {
        // code 0:成功 3:失败 5:异常中止
    }
});
```

#### 使用礼包码
**功能简介：** 此接口用于校验玩家在游戏盒中领取的礼包码，校验成功后游戏会发放对应礼包。  
**调用时机：** 初始化且回调后

```java
/*
 使用礼包码
 your_gift_id: 礼包ID，向运营获取
 your_gift_code: 礼包码，用户输入
*/
ExclusiveAgent.useGiftCode("your_gift_id", 
    "your_gift_code", 
    new OpeResultListener() {
    @Override
    public void onResult(int code, @Nullable String message) {
        // code 0:成功 3:失败 5:异常中止
    }
});
```

#### 游戏分享
**功能简介：** 此接口用于玩家在游戏内将游戏页面、图片分享到微信、QQ、游戏盒等外部 APP。  
**调用时机：** 初始化且回调后

- 分享接口接入之前，请联系相关运营人员配置分享方式
- 分享接口调用之前，需要游戏确保已申请sdcard读写权限
- 分享方式包括：
    - 游戏详情页分享（一般为带游戏名称、游戏LOGO、游戏说明的游戏盒H5链接）
    - 游戏图片分享（一般为带游戏下载二维码的游戏海报，游戏海报即一张图片，图片由游戏方提供，由运营人员上传至4399游戏后台）
- 游戏方可选择其中1种或2种分享方式

```java
/*
 游戏分享
*/
OperateCenter.getInstance().share(EaGameActivity.this);
```

#### 游戏评分
**功能简介：** 此接口调用后，将跳转到《4399 游戏盒》内对应游戏评分页面。  
**调用时机：** 初始化且回调后

```java
/*
 游戏评分
*/
OperateCenter.getInstance().comment(EaGameActivity.this);
```

#### 游戏盒跳转

**功能简介：** 原有的游戏盒跳转，即礼包、活动、论坛、游戏盒详情接口，迁移为通用跳转接口  
**调用时机：** 初始化且回调后

游戏开发者调用接口前，需要向运营获取跳转参数，跳转方式参考下文通用跳转接口

### 接口调用：通用跳转（推荐）

游戏可能需要多形式、更可变的跳转，对此 SDK 对此进行支持。使用这一特性需要：

- 运营配置跳转参数，如打开app页面、网页、下载、消息提示等
- 配置完成后得到跳转 key，交接给游戏
- 游戏使用此 key，调用下面的跳转接口
- 后续需要变更跳转方式，由运营重新配置即可

```java
// 先判断是否支持key的协议
OperateAction.support(key, new OpeResultListener() {
    @Override
    public void onResult(int code, @Nullable String msg) {
        // 使用key进行跳转，0为支持，其他不支持
        if (code == 0) { 
            OperateAction.perform(key);
        }
    }
});
```

极少数跳转需要传递额外参数，如图片分享，此时要使用另一个接口

```java
// 入口 key
// 跳转intent，intent的内部是键值对
OperateAction.perform(key, intent);
```

### 接口调用：云存档

**功能简介：** 单机类游戏中有些没有自己的服务器，但是又需要跨设备持久化；因此，SDK 提供云存档接口，游戏只需要初始化、读取、保存存档即可。  
**调用时机：** 账号登录后，进入游戏主界面前

使用这些接口前，游戏开发者需要：  
- 与运营确认使用云存档，运营会为游戏完成必要的配置，完成后得到`public key`公钥，这是调用接口的必要参数。
- <span style="color:red;font-weight:bold">明确账号变更时，数据保存-加载策略，一般要处理以下情况</span>
    - <span style="color:red">账号登录时，应读取新存档</span>
    - <span style="color:red">账号切换时，应保存旧账号的存档，并读取新账号存档</span>
    - <span style="color:red">账号登出时，应保存存档</span>

如此，可以保证存档不丢失、不混乱。以上情况可以参考代码下面初始化 `CloudArchive.init` 接口

#### 初始化

```java
// publicKey: 公钥，向运营获取公钥
CloudArchive.init(publicKey, new CloudArchive.InitListener() {
    @Override
    public void onSuccess() {
        //初始化成功
    }

    @Override
    public void onFailure(int code, @NonNull String message) {
        //初始化故障
    }

    @Override
    public void onUserChanged(@Nullable CloudArchive previousUserCloudArchive, @Nullable CloudArchive currentUserCloudArchive) {
        /*
          未登录 -> 账号A : previousUserCloudArchive == null && currentUserCloudArchive != null
          账号A -> 账号B : previousUserCloudArchive != null && currentUserCloudArchive != null
          账号A -> 未登录 : previousUserCloudArchive != null && currentUserCloudArchive == null
        */
        //登录用户改变
        if (previousUserCloudArchive != null) {
            //保存前一个登录用户的存档
            previousUserCloudArchive.save(getArchive(), new CloudArchive.SaveListener() {
                @Override
                public void onSuccess() {
                }

                @Override
                public void onFailure(int code, @NonNull String message) {
                }
            });
        }
        reset();//重置游戏
        if (currentUserCloudArchive != null) {
            //获取当前登录用户的存档列表
            currentUserCloudArchive.list(new CloudArchive.ListListener() {
                @Override
                public void onSuccess(@NonNull List<Archive> archiveList) {
                    
                }

                @Override
                public void onFailure(int code, @NonNull String message) {

                }
            });
        }
    }
});
```

其中`Archive`——存档定义如下：

```java
public class Archive {
    /**
     * 存档索引，建议使用[1,10]区间的正整数
     */
    public final int index;

    /**
     * 存档标题
     */
    public final String title;

    /**
     * 最后更新时间
     */
    public final int updateTime;

    /**
     * 存档数据
     */
    public final String data;
    ...
}
```

#### 存档列表

```java
CloudArchive.getCloudArchive().list(new CloudArchive.ListListener() {
    @Override
    public void onSuccess(@NonNull List<Archive> archiveList) {
        //获取存档列表成功
    }

    @Override
    public void onFailure(int code, @NonNull String message) {
        //获取存档列表故障
    }
});
```

#### 读取存档

```java
//index: 存档索引
CloudArchive.getCloudArchive().get(index, new CloudArchive.GetListener() {
    @Override
    public void onSuccess(@NonNull Archive archive) {
        //读取存档成功
    }

    @Override
    public void onFailure(int code, @NonNull String message) {
        //读取存档故障
    }
});
```

#### 保存存档

```java
//archive: 存档
CloudArchive.getCloudArchive().save(archive, new CloudArchive.SaveListener() {
    @Override
    public void onSuccess() {
        //保存存档成功
    }

    @Override
    public void onFailure(int code, @NonNull String message) {
        //保存存档故障
    }
});
```