Merge branch 'master' of https://github.com/reactnativecn/react-native-pushy
This commit is contained in:
commit
ea50016c96
@ -5,7 +5,7 @@
|
||||
### 优势
|
||||
|
||||
1. 命令行工具&网页双端管理,版本发布过程简单便捷,完全可以集成CI。
|
||||
2. 基于bsdiff算法创建的超小更新包,通常版本迭代后在1-10KB之间,避免数百KB的流量消耗。
|
||||
2. 基于bsdiff算法创建的**超小更新包**,通常版本迭代后在1-10KB之间,避免数百KB的流量消耗。
|
||||
3. 支持崩溃回滚,安全可靠。
|
||||
4. meta信息及开放API,提供更高扩展性。
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
|
||||
* [文档-快速入门-发布版本](docs/guide3.md)。
|
||||
|
||||
* [文档-常见问题与高级指南](docs/faq_advance.md)。
|
||||
|
||||
### 命令行工具
|
||||
|
||||
请查阅[文档-命令行工具](docs/cli.md)。
|
||||
|
@ -61,7 +61,7 @@ class DownloadTask extends AsyncTask<DownloadTaskParams, Void, Void> {
|
||||
removeDirectory(f);
|
||||
}
|
||||
}
|
||||
if (!file.delete()) {
|
||||
if (file.exists() && !file.delete()) {
|
||||
throw new IOException("Failed to delete directory");
|
||||
}
|
||||
}
|
||||
|
@ -128,6 +128,7 @@ public class UpdateContext {
|
||||
public void markSuccess() {
|
||||
SharedPreferences.Editor editor = sp.edit();
|
||||
editor.putBoolean("firstTimeOk", true);
|
||||
editor.remove("lastVersion");
|
||||
editor.apply();
|
||||
|
||||
this.clearUp();
|
||||
|
66
docs/api.md
Normal file
66
docs/api.md
Normal file
@ -0,0 +1,66 @@
|
||||
# API
|
||||
|
||||
### downloadRootDir
|
||||
|
||||
下载的根目录。你可以使用react-native-fs等第三方组件检查其中的内容。
|
||||
|
||||
### packageVersion
|
||||
|
||||
当前应用包的版本名。
|
||||
|
||||
### currentVersion
|
||||
|
||||
当前版本的Hash号。
|
||||
|
||||
### isFirstTime
|
||||
|
||||
是否更新后的首次启动。当此项为真时,你需要在合适的时候调用`markSuccess()`以确保更新成功。否则应用下一次启动时将会回滚。
|
||||
|
||||
### isRolledBack
|
||||
|
||||
是否刚刚经历了一次回滚。
|
||||
|
||||
### async function checkUpdate(appKey)
|
||||
|
||||
检查更新,返回值有三种情形:
|
||||
|
||||
1. `{expired: true}`:该应用包(原生部分)已过期,需要前往应用市场下载新的版本。
|
||||
```
|
||||
{
|
||||
expired: true,
|
||||
downloadUrl: 'http://appstore/downloadUrl',
|
||||
}
|
||||
```
|
||||
2. `{upToDate: true}`:当前已经更新到最新,无需进行更新。
|
||||
|
||||
3. `{update: true}`:当前有新版本可以更新。info的`name`、`description`字段可
|
||||
以用于提示用户,而`metaInfo`字段则可以根据你的需求自定义其它属性(如是否静默更新、
|
||||
是否强制更新等等)。另外还有几个字段,包含了完整更新包或补丁包的下载地址,
|
||||
```
|
||||
{
|
||||
update: true,
|
||||
name: '1.0.3-rc',
|
||||
hash: 'hash',
|
||||
description: '添加聊天功能\n修复商城页面BUG',
|
||||
metaInfo: '{"silent":true}',
|
||||
updateUrl: 'http://update-packages.reactnative.cn/hash',
|
||||
pdiffUrl: 'http://update-packages.reactnative.cn/hash',
|
||||
diffUrl: 'http://update-packages.reactnative.cn/hash',
|
||||
}
|
||||
```
|
||||
|
||||
### async function downloadUpdate(info)
|
||||
|
||||
下载更新版本。info为checkUpdate函数的返回值,并且仅当`update:true`时实际进行下载。
|
||||
|
||||
### function switchVersion(hash)
|
||||
|
||||
立即重启应用,并加载已经下载完毕的版本。
|
||||
|
||||
### function switchVersionLater(hash)
|
||||
|
||||
在下一次启动应用的时候加载已经下载完毕的版本。
|
||||
|
||||
### function markSuccess()
|
||||
|
||||
在isFirstTime为true时需在应用成功启动后调用此函数,
|
118
docs/cli.md
Normal file
118
docs/cli.md
Normal file
@ -0,0 +1,118 @@
|
||||
# 命令行工具
|
||||
|
||||
## 安装
|
||||
|
||||
```
|
||||
$ npm install -g react-native-update-cli rnpm
|
||||
$ npm install --save react-native-update
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
#### pushy bundle
|
||||
|
||||
生成资源包
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
* entryFile: 入口脚本文件
|
||||
* intermediaDir: 临时文件输出目录
|
||||
* output: 最终ppk文件输出路径
|
||||
* dev: 是否打包开发版本
|
||||
* verbose: 是否展现打包过程的详细信息
|
||||
|
||||
#### pushy diff <origin> <next>
|
||||
|
||||
提供两个ppk文件,生成从origin到next版本的差异更新包。
|
||||
|
||||
* output: diff文件输出路径
|
||||
|
||||
#### pushy diffFromApk <apkFile> <next>
|
||||
|
||||
提供一个apk文件和一个ppk文件,生成从ipa文件到next版本的差异更新包。
|
||||
|
||||
如果使用热更新开放平台,你不需要自己执行此命令。
|
||||
|
||||
* output: diff文件输出路径
|
||||
|
||||
#### pushy diffFromIpa <ipaFile> <next>
|
||||
|
||||
提供一个ipa文件和一个ppk文件,生成从ipa文件到next版本的差异更新包。
|
||||
|
||||
如果使用热更新开放平台,你不需要自己执行此命令。
|
||||
|
||||
* output: diff文件输出路径
|
||||
|
||||
#### pushy login [<email>] [<pwd>]
|
||||
|
||||
登录热更新开放平台。你需要先登录才能使用下面的命令。
|
||||
|
||||
#### pushy logout
|
||||
|
||||
登出并清除本地的登录信息
|
||||
|
||||
#### pushy me
|
||||
|
||||
查看自己是否已经登录,以及昵称等信息。
|
||||
|
||||
#### pushy createApp
|
||||
|
||||
创建应用并立刻绑定到当前工程。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
* name: 应用名称
|
||||
* downloadUrl: 应用安装包的下载地址
|
||||
|
||||
#### pushy deleteApp [appId]
|
||||
|
||||
删除已有应用。所有已创建的应用包、热更新版本都会被同时删除。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
|
||||
#### pushy apps
|
||||
|
||||
查看当前已创建的全部应用。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
|
||||
#### pushy selectApp [appId]
|
||||
|
||||
绑定应用到当前工程。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
|
||||
#### pushy uploadIpa <ipaFile>
|
||||
|
||||
上传ipa文件到开放平台。
|
||||
|
||||
#### pushy uploadApk <apkFile>
|
||||
|
||||
上传apk文件到开放平台。
|
||||
|
||||
#### pushy packages
|
||||
|
||||
查看已经上传的包。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
|
||||
#### pushy publish <ppkFile>
|
||||
|
||||
发布新的更新版本。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
* name: 当前版本的名字(版本号)
|
||||
* description: 当前版本的描述信息,可以对用户进行展示
|
||||
* metaInfo: 当前版本的元信息,可以用来保存一些额外信息
|
||||
|
||||
#### pushy versions
|
||||
|
||||
分页列举可用的版本。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
|
||||
#### pushy update
|
||||
|
||||
为一个包版本绑定一个更新版本。这项操作也可以在网页管理端进行。
|
||||
|
||||
* platform: ios|android 对应的平台
|
||||
* versionId: 要绑定的版本ID
|
||||
* packageId: 要绑定的包ID
|
59
docs/faq_advance.md
Normal file
59
docs/faq_advance.md
Normal file
@ -0,0 +1,59 @@
|
||||
## 高级指南
|
||||
|
||||
#### 报错 NDK not configured.
|
||||
|
||||
你需要下载并安装NDK,然后设置到环境变量`ANDROID_NDK`中_
|
||||
|
||||
#### 过期的版本
|
||||
|
||||
你可以删除掉过期很久的版本。在一段时间后,版本会被真正清理。
|
||||
|
||||
如果有用户还处在已经被清理的版本上,当他发起更新的时候,将不能通过版本差异比对进行更新,因此可能需要更新的体积较大。但这不会导致用户不能更新到新的版本。
|
||||
|
||||
#### CI的集成
|
||||
|
||||
在开发环境中,每次bundle都会生成一个不同名字的ppk文件,这不利于持续集成(CI)系统的引入。
|
||||
|
||||
要解决这个问题,你可以使用`--output`参数来指定输出ppk文件的名字和路径,便于进行自动发布。
|
||||
|
||||
#### 版本测试与发布
|
||||
|
||||
我们强烈建议您先发布一个测试包,再发布一个除了版本号以外均完全相同的发布包。
|
||||
在每次往发布包发起热更新之前,先往对应的测试包进行更新操作,基本测试通过之后,可以将发布包更新到完全相同的热更新版本之上。
|
||||
这样,可以最大程度的避免用户通过热更新获得一个有问题的版本。
|
||||
|
||||
#### 元信息(Meta Info)的使用
|
||||
|
||||
在发布热更新版本时,或者在网页端,你可以编辑版本的元信息。
|
||||
这是一段在检查更新时可以获得的字符串,你可以在其中按你所想的格式保存一些信息。
|
||||
|
||||
举例来说,可能某个版本包含一些重要的更新内容,所以用户会得到一个不同样式的通知。如何使用元信息,完全取决于您的想象力!
|
||||
|
||||
下面会列举一些实战中更有意义的元信息的使用。
|
||||
|
||||
#### Hot-fix
|
||||
|
||||
有时候我们不小心发布了一个有严重问题的版本,所以需要进行一个紧急的修复,
|
||||
此时我们可能希望之前已经更新到有问题版本的用户进行紧急甚至静默进行更新。
|
||||
|
||||
这时候,我们可以在元信息中包含有问题的版本的列表,而在客户端检查更新时,将从元信息里取到的列表与当前版本(currentVersion)比对,
|
||||
如果匹配成功,我们就进行静默更新,否则则按照一般的更新流程提示用户。
|
||||
|
||||
## 常见问题
|
||||
|
||||
#### 这个热更新服务收费吗?
|
||||
|
||||
目前我们的热更新服务完全免费,但限制每个账号不超过3个应用;每个应用不超过10个活跃的包和100个活跃的热更新版本;每个应用每个月不超过10000次下载。iOS和Android版本记做不同的应用。
|
||||
|
||||
已经移除的应用、包版本、热更新版本不在统计之列,所以你可以移除测试时产生的和已过期版本来更有效的利用空间。
|
||||
|
||||
我们会在将来推出付费的升级版本,针对用户量较大、版本迭代较快的用户提供扩容方案。如果您有急迫的需求,可以[联系我们](http://reactnative.cn/about.html#content)。
|
||||
|
||||
#### 我是否可以搭建自己的热更新服务?
|
||||
|
||||
你可以单独使用本组件的原生部分(不包括js模块)和命令行工具中的`bundle`、`diff`、`diffFromIpa`、`diffFromApk`四个功能。
|
||||
|
||||
这些功能都不会使用我们的热更新服务,也无需注册或登录账号。但你可能要编写自己的js模块来与不同的热更新服务器通讯。
|
||||
|
||||
如果您有兴趣使用我们的成果,搭建私有云服务,可以[联系我们](http://reactnative.cn/about.html#content)。
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
所以我们也假设你已经拥有了开发React Native应用的一切环境,包括`Node.js`、`npm`、`XCode`、`Android SDK`等等。
|
||||
|
||||
如果你之前没安装过,你还必须安装[Android NDK](http://androiddevtools.cn),并设置环境变量`ANDROID_NDK`,指向你的NDK根目录(例如`/Users/tdzl2003/Downloads/android-ndk-r10e`)。
|
||||
如果你之前没安装过,你还必须安装[Android NDK](http://androiddevtools.cn),并设置环境变量`ANDROID_NDK_HOME`,指向你的NDK根目录(例如`/Users/tdzl2003/Downloads/android-ndk-r10e`)。
|
||||
|
||||
## 安装
|
||||
|
||||
@ -32,7 +32,6 @@ $ rnpm link react-native-update
|
||||
// ... 其它代码
|
||||
|
||||
import cn.reactnative.modules.update.UpdateContext;
|
||||
import cn.reactnative.modules.update.UpdatePackage;
|
||||
|
||||
public class MainActivity extends ReactActivity {
|
||||
|
||||
@ -72,9 +71,9 @@ App Name: <输入应用名字>
|
||||
```bash
|
||||
$ pushy selectApp --platform ios
|
||||
1) 鱼多多(ios)
|
||||
3) 支付宝(ios)
|
||||
3) 招财旺(ios)
|
||||
|
||||
Total 2 apps
|
||||
Total 2 ios apps
|
||||
Enter appId: <输入应用前面的编号>
|
||||
```
|
||||
|
||||
|
@ -80,7 +80,7 @@ import {
|
||||
markSuccess,
|
||||
} from 'react-native-update';
|
||||
|
||||
import _updateConfig from '../update.json';
|
||||
import _updateConfig from './update.json';
|
||||
const {appKey} = _updateConfig[Platform.OS];
|
||||
|
||||
class MyProject extends Component {
|
||||
@ -114,7 +114,7 @@ class MyProject extends Component {
|
||||
} else if (info.upToDate) {
|
||||
Alert.alert('提示', '您的应用版本已是最新.');
|
||||
} else {
|
||||
Alert.alert('提示', '检查到新的版本,是否下载?\n'+ info.description, [
|
||||
Alert.alert('提示', '检查到新的版本'+info.name+',是否下载?\n'+ info.description, [
|
||||
{text: '是', onPress: ()=>{this.doUpdate(info)}},
|
||||
{text: '否',},
|
||||
]);
|
||||
|
@ -2,17 +2,79 @@
|
||||
|
||||
现在你的应用已经具备了检测更新的功能,下面我们来尝试发布并更新它。
|
||||
|
||||
> **注意**,从update上传发布版本到发布版本正式上线期间,不要修改任何脚本和资源,这会影响update
|
||||
获取本地代码,从而导致版本不能更新。如果在发布之前修改了脚本或资源,请在网页端删除之前上传的版本并重新上传。
|
||||
|
||||
## 发布iOS应用
|
||||
|
||||
首先参考[文档-在设备上运行](http://reactnative.cn/docs/0.22/running-on-device-ios.html#content),
|
||||
确定你正在使用离线包。然后点击菜单。
|
||||
|
||||
注意,从update上传发布版本到发布版本正式上线期间,不要修改任何脚本和资源,这会影响update
|
||||
获取本地代码,从而导致版本不能更新。如果在发布之前修改了脚本或资源,请在网页端删除之前上传的版本并重新上传。
|
||||
|
||||
按照正常的发布流程打包`.ipa`文件(Product-Achieve),然后运行如下命令
|
||||
|
||||
```bash
|
||||
$ pushy uploadIpa <your-package.ipa>
|
||||
```
|
||||
|
||||
即可上传ipa以供后续版本比对之用。
|
||||
|
||||
随后你可以选择往AppStore发布这个版本,也可以先通过Test flight等方法进行测试。
|
||||
|
||||
## 发布安卓应用
|
||||
|
||||
首先参考[文档-生成已签名的APK](http://reactnative.cn/docs/0.22/signed-apk-android.html#content)设置签名,
|
||||
然后在android文件夹下运行`./gradlew buildRelease`,你就可以在`android/app/build/outputs/apk/app-release.apk`中找到你的应用包。
|
||||
|
||||
然后运行如下命令
|
||||
|
||||
```bash
|
||||
$ pushy uploadApk android/app/build/outputs/apk/app-release.apk
|
||||
```
|
||||
|
||||
即可上传apk以供后续版本比对之用。
|
||||
|
||||
随后你可以选择往应用市场发布这个版本,也可以先往设备上直接安装这个apk文件以进行测试。
|
||||
|
||||
## 发布新的热更新版本
|
||||
|
||||
你可以尝试修改一行代码(譬如将版本一修改为版本二),然后生成新的热更新版本。
|
||||
|
||||
```bash
|
||||
$ pushy bundle --platform <ios|android>
|
||||
Bundling with React Native version: 0.22.2
|
||||
<各种进度输出>
|
||||
Bundled saved to: build/output/android.1459850548545.ppk
|
||||
Would you like to publish it?(Y/N)
|
||||
```
|
||||
|
||||
如果想要立即发布,此时输入Y。当然,你也可以在将来使用`pushy publish --platform <ios|android> <ppkFile>`来发布版本。
|
||||
|
||||
```
|
||||
Uploading [========================================================] 100% 0.0s
|
||||
Enter version name: <输入版本名字,如1.0.0-rc>
|
||||
Enter description: <输入版本描述>
|
||||
Enter meta info: {"ok":1}
|
||||
Ok.
|
||||
Would you like to bind packages to this version?(Y/N)
|
||||
```
|
||||
|
||||
此时版本已经提交到update服务,但用户暂时看不到此更新,你需要先将特定的包版本绑定到此热更新版本上。
|
||||
|
||||
此时输入Y立即绑定,你也可以在将来使用`pushy update --platform <ios|android>`来使得对应包版本的用户更新。
|
||||
除此以外,你还可以在网页端操作,简单的将对应的包版本拖到此版本下即可。
|
||||
|
||||
```
|
||||
Offset 0
|
||||
1) FvXnROJ1 1.0.1 (no package)
|
||||
2) FiWYm9lB 1.0 [1.0]
|
||||
Enter versionId or page Up/page Down/Begin(U/D/B) <输入序号,U/D翻页,B回到开始>
|
||||
|
||||
1) 1.0(normal) - 3 FiWYm9lB (未命名)
|
||||
|
||||
Total 1 packages.
|
||||
Enter packageId: <输入包版本序号>
|
||||
```
|
||||
|
||||
版本绑定完毕后,客户端就应当可以检查到更新并进行更新了。
|
||||
|
||||
恭喜你,至此为止,你已经完成了植入代码热更新的全部工作。接下来,你可以查阅[常见问题与高级指南](faq_advance.md)了解更多深入的知识,尤其是在实际项目中的运用技巧。
|
||||
|
@ -79,6 +79,12 @@
|
||||
"options": {
|
||||
"platform": {
|
||||
"hasValue": true
|
||||
},
|
||||
"versionId": {
|
||||
"hasValue": true
|
||||
},
|
||||
"packageId": {
|
||||
"hasValue": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -65,16 +65,18 @@ export async function chooseApp(platform) {
|
||||
export const commands = {
|
||||
createApp: async function ({options}) {
|
||||
const name = options.name || await question('App Name:');
|
||||
const {downloadUrl} = options;
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
const {id} = await post('/app/create', {name, platform});
|
||||
console.log(`Created app ${id}`);
|
||||
await this.selectApp({
|
||||
args: [id],
|
||||
options: {platform},
|
||||
options: {platform, downloadUrl},
|
||||
});
|
||||
},
|
||||
deleteApp: async function ({args}) {
|
||||
const id = args[0] || ((await this.apps()), (await question('Choose App to delete:')));
|
||||
deleteApp: async function ({args, options}) {
|
||||
const {platform} = options;
|
||||
const id = args[0] || chooseApp(platform);
|
||||
if (!id) {
|
||||
console.log('Canceled');
|
||||
}
|
||||
@ -86,8 +88,8 @@ export const commands = {
|
||||
listApp(platform);
|
||||
},
|
||||
selectApp: async function({args, options}) {
|
||||
const {platform} = options;
|
||||
checkPlatform(platform);
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
checkPlatform(platform || await question('Platform(ios/android):'));
|
||||
const id = args[0] || (await chooseApp(platform)).id;
|
||||
|
||||
let updateInfo = {};
|
||||
|
@ -13,7 +13,7 @@ import {ZipFile} from 'yazl';
|
||||
import {open as openZipFile} from 'yauzl';
|
||||
import {diff} from 'node-bsdiff';
|
||||
import { question } from './utils';
|
||||
|
||||
import {checkPlatform} from './app';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function mkdir(dir){
|
||||
@ -316,14 +316,16 @@ function enumZipEntries(zipFn, callback) {
|
||||
|
||||
export const commands = {
|
||||
bundle: async function({options}){
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
|
||||
const {
|
||||
entryFile,
|
||||
intermediaDir,
|
||||
platform,
|
||||
output,
|
||||
dev,
|
||||
verbose
|
||||
} = translateOptions(options);
|
||||
} = translateOptions({...options, platform});
|
||||
|
||||
const realOutput = output.replace(/\$\{time\}/g, '' + Date.now());
|
||||
|
||||
if (!platform) {
|
||||
|
@ -75,7 +75,7 @@ export const commands = {
|
||||
console.log('Ok.');
|
||||
},
|
||||
packages: async function({options}) {
|
||||
const { platform } = options;
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
const { appId } = await getSelectedApp(platform);
|
||||
await listPackage(appId);
|
||||
},
|
||||
|
@ -69,10 +69,13 @@ async function chooseVersion(appId) {
|
||||
export const commands = {
|
||||
publish: async function({args, options}) {
|
||||
const fn = args[0];
|
||||
const {platform, name, description, metaInfo } = options;
|
||||
if (!fn || !platform) {
|
||||
const {name, description, metaInfo } = options;
|
||||
|
||||
if (!fn) {
|
||||
throw new Error('Usage: pushy publish <ppkFile> --platform ios|android');
|
||||
}
|
||||
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
const { appId } = await getSelectedApp(platform);
|
||||
|
||||
const { hash } = await uploadFile(fn);
|
||||
@ -87,21 +90,21 @@ export const commands = {
|
||||
|
||||
const v = await question('Would you like to bind packages to this version?(Y/N)');
|
||||
if (v.toLowerCase() === 'y') {
|
||||
await this.update({args:[], options:{packageId: id, platform}});
|
||||
await this.update({args:[], options:{versionId: id, platform}});
|
||||
}
|
||||
},
|
||||
versions: async function({options}) {
|
||||
const { platform } = options;
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
const { appId } = await getSelectedApp(platform);
|
||||
await listVersions(appId);
|
||||
},
|
||||
update: async function({args, options}) {
|
||||
const { platform } = options;
|
||||
const platform = checkPlatform(options.platform || await question('Platform(ios/android):'));
|
||||
const { appId } = await getSelectedApp(platform);
|
||||
const version = await chooseVersion(appId);
|
||||
const pkg = await choosePackage(appId);
|
||||
await put(`/app/${appId}/package/${pkg.id}`, {
|
||||
versionId: version.id,
|
||||
const versionId = options.versionId || (await chooseVersion(appId)).id;
|
||||
const pkgId = options.packageId || (await choosePackage(appId)).id;
|
||||
await put(`/app/${appId}/package/${pkgId}`, {
|
||||
versionId,
|
||||
});
|
||||
console.log('Ok.');
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user