working on android
This commit is contained in:
parent
2765d2f4be
commit
dbe1f9b26d
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@
|
||||
/local-cli/lib
|
||||
/react-native-pushy-cli/node_modules
|
||||
/react-native-pushy-cli/lib
|
||||
|
||||
*.iml
|
||||
|
@ -7,3 +7,4 @@
|
||||
/local-cli/src
|
||||
/react-native-pushy-cli
|
||||
/Example
|
||||
/android/build
|
||||
|
@ -1,51 +1 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*/
|
||||
'use strict';
|
||||
import React, {
|
||||
AppRegistry,
|
||||
Component,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
class testHotUpdate extends Component {
|
||||
render() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.welcome}>
|
||||
Welcome to React Native!
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
To get started, edit index.android.js
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
Shake or press menu button for dev menu
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F5FCFF',
|
||||
},
|
||||
welcome: {
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
margin: 10,
|
||||
},
|
||||
instructions: {
|
||||
textAlign: 'center',
|
||||
color: '#333333',
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
AppRegistry.registerComponent('testHotUpdate', () => testHotUpdate);
|
||||
require('./js/index');
|
||||
|
@ -1,69 +1 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*/
|
||||
'use strict';
|
||||
import React, {
|
||||
AppRegistry,
|
||||
Component,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
|
||||
import {downloadFile, reloadUpdate} from 'react-native-update'
|
||||
|
||||
class testHotUpdate extends Component {
|
||||
render() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.welcome}>
|
||||
Welcome to React Native!
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
To get started, edit index.ios.js
|
||||
</Text>
|
||||
<TouchableOpacity onPress={
|
||||
()=>{
|
||||
downloadFile({updateUrl:'http://7xjhby.com2.z0.glb.qiniucdn.com/ios1.ppk', hashName:'test'})
|
||||
}
|
||||
}>
|
||||
<Text style={styles.instructions}>
|
||||
Press To DownloadFile
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={
|
||||
()=>{
|
||||
reloadUpdate({hashName:'test'})
|
||||
}
|
||||
}>
|
||||
<Text style={styles.instructions}>
|
||||
Press To Reload
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F5FCFF',
|
||||
},
|
||||
welcome: {
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
margin: 10,
|
||||
},
|
||||
instructions: {
|
||||
textAlign: 'center',
|
||||
color: '#333333',
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
AppRegistry.registerComponent('testHotUpdate', () => testHotUpdate);
|
||||
require('./js/index');
|
||||
|
69
Example/testHotUpdate/js/index.js
Normal file
69
Example/testHotUpdate/js/index.js
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*/
|
||||
'use strict';
|
||||
import React, {
|
||||
AppRegistry,
|
||||
Component,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
|
||||
import {downloadFile, reloadUpdate} from 'react-native-update'
|
||||
|
||||
class testHotUpdate extends Component {
|
||||
render() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.welcome}>
|
||||
Welcome to React Native!
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
To get started, edit index.ios.js
|
||||
</Text>
|
||||
<TouchableOpacity onPress={
|
||||
()=>{
|
||||
downloadFile({updateUrl:'http://7xjhby.com2.z0.glb.qiniucdn.com/ios1.ppk', hashName:'test'})
|
||||
}
|
||||
}>
|
||||
<Text style={styles.instructions}>
|
||||
Press To DownloadFile
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={
|
||||
()=>{
|
||||
reloadUpdate({hashName:'test'})
|
||||
}
|
||||
}>
|
||||
<Text style={styles.instructions}>
|
||||
Press To Reload
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F5FCFF',
|
||||
},
|
||||
welcome: {
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
margin: 10,
|
||||
},
|
||||
instructions: {
|
||||
textAlign: 'center',
|
||||
color: '#333333',
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
AppRegistry.registerComponent('testHotUpdate', () => testHotUpdate);
|
@ -7,6 +7,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react-native": "^0.20.0",
|
||||
"react-native-update": "git+https://github.com/reactnativecn/react-native-pushy"
|
||||
"react-native-pushy": "../.."
|
||||
}
|
||||
}
|
||||
|
17
android/build.gradle
Normal file
17
android/build.gradle
Normal file
@ -0,0 +1,17 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
compileSdkVersion 23
|
||||
buildToolsVersion "23.0.1"
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 23
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile 'com.facebook.react:react-native:+'
|
||||
}
|
5
android/src/main/AndroidManifest.xml
Normal file
5
android/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="cn.reactnative.modules.update">
|
||||
|
||||
</manifest>
|
@ -0,0 +1,115 @@
|
||||
package cn.reactnative.modules.update;
|
||||
|
||||
import android.os.AsyncTask;
|
||||
import android.util.Log;
|
||||
|
||||
import com.squareup.okhttp.OkHttpClient;
|
||||
import com.squareup.okhttp.Request;
|
||||
import com.squareup.okhttp.Response;
|
||||
import com.squareup.okhttp.ResponseBody;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import okio.BufferedSink;
|
||||
import okio.BufferedSource;
|
||||
import okio.Okio;
|
||||
|
||||
/**
|
||||
* Created by tdzl2003 on 3/31/16.
|
||||
*/
|
||||
class DownloadTask extends AsyncTask<DownloadTaskParams, Void, Void> {
|
||||
final int DOWNLOAD_CHUNK_SIZE = 4096;
|
||||
|
||||
@Override
|
||||
protected Void doInBackground(DownloadTaskParams... params) {
|
||||
DownloadTaskParams param = params[0];
|
||||
try {
|
||||
OkHttpClient client = new OkHttpClient();
|
||||
Request request = new Request.Builder().url(param.url)
|
||||
.build();
|
||||
Response response = client.newCall(request).execute();
|
||||
ResponseBody body = response.body();
|
||||
long contentLength = body.contentLength();
|
||||
BufferedSource source = body.source();
|
||||
|
||||
if (param.zipFilePath.exists()) {
|
||||
param.zipFilePath.delete();
|
||||
}
|
||||
|
||||
BufferedSink sink = Okio.buffer(Okio.sink(param.zipFilePath));
|
||||
|
||||
if (UpdateContext.DEBUG) {
|
||||
Log.d("RNUpdate", "Downloading " + param.url);
|
||||
}
|
||||
|
||||
long bytesRead = 0;
|
||||
long totalRead = 0;
|
||||
while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) {
|
||||
totalRead += bytesRead;
|
||||
if (UpdateContext.DEBUG) {
|
||||
Log.d("RNUpdate", "Progress " + totalRead + "/" + contentLength);
|
||||
}
|
||||
}
|
||||
if (totalRead != contentLength) {
|
||||
throw new Error("Unexpected eof while reading ppk");
|
||||
}
|
||||
sink.writeAll(source);
|
||||
sink.close();
|
||||
|
||||
if (UpdateContext.DEBUG) {
|
||||
Log.d("RNUpdate", "Download finished");
|
||||
}
|
||||
|
||||
ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new FileInputStream(param.zipFilePath)));
|
||||
ZipEntry ze;
|
||||
byte[] buffer = new byte[1024];
|
||||
int count;
|
||||
String filename;
|
||||
|
||||
param.unzipDirectory.mkdirs();
|
||||
|
||||
while ((ze = zis.getNextEntry()) != null)
|
||||
{
|
||||
String fn = ze.getName();
|
||||
File fmd = new File(param.unzipDirectory, fn);
|
||||
|
||||
if (UpdateContext.DEBUG) {
|
||||
Log.d("RNUpdate", "Unzipping " + fn);
|
||||
}
|
||||
|
||||
if (ze.isDirectory()) {
|
||||
fmd.mkdirs();
|
||||
continue;
|
||||
}
|
||||
|
||||
FileOutputStream fout = new FileOutputStream(fmd);
|
||||
|
||||
while ((count = zis.read(buffer)) != -1)
|
||||
{
|
||||
fout.write(buffer, 0, count);
|
||||
}
|
||||
|
||||
fout.close();
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
||||
zis.close();
|
||||
|
||||
if (UpdateContext.DEBUG) {
|
||||
Log.d("RNUpdate", "Unzip finished");
|
||||
}
|
||||
|
||||
} catch (Throwable e) {
|
||||
param.listener.onDownloadFailed(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package cn.reactnative.modules.update;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Created by tdzl2003 on 3/31/16.
|
||||
*/
|
||||
class DownloadTaskParams {
|
||||
String url;
|
||||
String hash;
|
||||
File zipFilePath;
|
||||
File unzipDirectory;
|
||||
UpdateContext.DownloadFileListener listener;
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package cn.reactnative.modules.update;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Created by tdzl2003 on 3/31/16.
|
||||
*/
|
||||
public class UpdateContext {
|
||||
private Context context;
|
||||
private File rootDir;
|
||||
|
||||
public static boolean DEBUG = false;
|
||||
|
||||
|
||||
private static UpdateContext currentInstance = null;
|
||||
|
||||
static UpdateContext instance() {
|
||||
return currentInstance;
|
||||
}
|
||||
|
||||
public UpdateContext(Context context) {
|
||||
this.context = context;
|
||||
|
||||
this.rootDir = new File(context.getFilesDir(), "_update");
|
||||
|
||||
if (!rootDir.exists()) {
|
||||
rootDir.mkdir();
|
||||
}
|
||||
}
|
||||
|
||||
public String getRootDir() {
|
||||
return rootDir.toString();
|
||||
}
|
||||
|
||||
public interface DownloadFileListener {
|
||||
void onDownloadCompleted();
|
||||
void onDownloadFailed(Throwable error);
|
||||
}
|
||||
|
||||
public void downloadFile(String url, String hashName, DownloadFileListener listener) {
|
||||
DownloadTaskParams params = new DownloadTaskParams();
|
||||
params.url = url;
|
||||
params.hash = hashName;
|
||||
params.listener = listener;
|
||||
params.zipFilePath = new File(rootDir, hashName + ".ppk");
|
||||
params.unzipDirectory = new File(rootDir, hashName);
|
||||
new DownloadTask().execute(params);
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package cn.reactnative.modules.update;
|
||||
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Created by tdzl2003 on 3/31/16.
|
||||
*/
|
||||
public class UpdateModule extends ReactContextBaseJavaModule{
|
||||
UpdateContext updateContext;
|
||||
|
||||
public UpdateModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
this.updateContext = new UpdateContext(reactContext.getApplicationContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getConstants() {
|
||||
final Map<String, Object> constants = new HashMap<>();
|
||||
constants.put("downloadRootDir", updateContext.getRootDir());
|
||||
return constants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "RCTHotUpdate";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void downloadUpdate(ReadableMap options, final Promise promise){
|
||||
String url = options.getString("updateUrl");
|
||||
String hash = options.getString("hashName");
|
||||
updateContext.downloadFile(url, hash, new UpdateContext.DownloadFileListener() {
|
||||
@Override
|
||||
public void onDownloadCompleted() {
|
||||
promise.resolve(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDownloadFailed(Throwable error) {
|
||||
promise.reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package cn.reactnative.modules.update;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.JavaScriptModule;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Created by tdzl2003 on 3/31/16.
|
||||
*/
|
||||
public class UpdatePackage implements ReactPackage {
|
||||
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
return Arrays.asList(new NativeModule[]{
|
||||
// Modules from third-party
|
||||
new UpdateModule(reactContext),
|
||||
});
|
||||
}
|
||||
|
||||
public List<Class<? extends JavaScriptModule>> createJSModules() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
1
index.js
1
index.js
@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Created by tdzl2003 on 2/6/16.
|
||||
* @providesModule react-native-update
|
||||
*/
|
||||
|
||||
//if (__DEV__){
|
||||
|
@ -85,8 +85,6 @@ function getPathOnDevserver(devServerUrl, asset) {
|
||||
* Returns a path like 'assets/AwesomeModule'
|
||||
*/
|
||||
function getBasePath(asset) {
|
||||
// TODO(frantic): currently httpServerLocation is used both as
|
||||
// path in http URL and path within IPA. Should we have zipArchiveLocation?
|
||||
var path = asset.httpServerLocation;
|
||||
if (path[0] === '/') {
|
||||
path = path.substr(1);
|
||||
|
@ -42,7 +42,7 @@
|
||||
"hasValue": true
|
||||
},
|
||||
"output": {
|
||||
"default": "build/output/${platform}.${hash}.ppk",
|
||||
"default": "build/output/${platform}.${time}.ppk",
|
||||
"hasValue": true
|
||||
},
|
||||
"verbose": {
|
||||
|
@ -26,38 +26,9 @@ function mkdir(dir){
|
||||
});
|
||||
}
|
||||
|
||||
function calcMd5ForFile(fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var hash = crypto.createHash('md5'),
|
||||
stream = fs.createReadStream(fn);
|
||||
|
||||
stream.on('data', (data) => hash.update(data, 'utf8'));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', err => reject(err));
|
||||
})
|
||||
}
|
||||
|
||||
async function calcMd5ForDirectory(dir) {
|
||||
const childs = fs.readdirSync(dir).sort();
|
||||
const result = {};
|
||||
for (const name of childs) {
|
||||
const fullPath = path.join(dir, name);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isFile()) {
|
||||
result[name] = 'file:' + await calcMd5ForFile(fullPath);
|
||||
} else {
|
||||
result[name] = 'directory:' + await calcMd5ForDirectory(fullPath);
|
||||
}
|
||||
}
|
||||
var hash = crypto.createHash('md5');
|
||||
hash.update(JSON.stringify(result), 'md5');
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function pack(dir, output){
|
||||
const hash = await calcMd5ForDirectory(dir);
|
||||
const realOutput = output.replace(/\$\{hash\}/g, hash);
|
||||
await mkdir(path.dirname(realOutput))
|
||||
const realOutput = output.replace(/\$\{time\}/g, '' + Date.now());
|
||||
await mkdir(path.dirname(realOutput));
|
||||
await new Promise((resolve, reject) => {
|
||||
var zipfile = new ZipFile();
|
||||
|
||||
@ -91,7 +62,7 @@ async function pack(dir, output){
|
||||
});
|
||||
zipfile.end();
|
||||
});
|
||||
console.log('Bundled with hash: ' + hash);
|
||||
console.log('Bundled saved to: ' + realOutput);
|
||||
}
|
||||
|
||||
function enumZipEntries(zipFn, callback) {
|
||||
@ -207,20 +178,15 @@ export const commands = {
|
||||
} else {
|
||||
// If same file.
|
||||
if (originEntries[entry.fileName] === entry.crc32) {
|
||||
console.log('keep:', entry.fileName);
|
||||
copies[entry.fileName] = 1;
|
||||
return;
|
||||
}
|
||||
// If moved from other place
|
||||
if (originMap[entry.crc32]){
|
||||
console.log('move:' + originMap[entry.crc32] + '->' + entry.fileName);
|
||||
copies[entry.fileName] = originMap[entry.crc32];
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: test diff
|
||||
console.log('add:' + entry.fileName);
|
||||
|
||||
return new Promise((resolve, reject)=>{
|
||||
nextZipfile.openReadStream(entry, function(err, readStream) {
|
||||
if (err){
|
||||
|
@ -31,6 +31,7 @@
|
||||
"fs-promise": "^0.4.1",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"mkdir-recursive": "^0.2.1",
|
||||
"node-bsdiff": "^0.1.1",
|
||||
"read": "^1.0.7",
|
||||
"yauzl": "^2.4.1",
|
||||
"yazl": "^2.3.0"
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "react-native-pushy-cli",
|
||||
"name": "react-native-update-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Command tools for javaScript updater with `pushy` service for react native apps.",
|
||||
"main": "index.js",
|
||||
|
12
react-native-pushy-cli/src/cli.js
vendored
12
react-native-pushy-cli/src/cli.js
vendored
@ -10,7 +10,7 @@ const CLI_MODULE_PATH = function() {
|
||||
return path.resolve(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'react-native-pushy',
|
||||
'react-native-update',
|
||||
'local-cli'
|
||||
);
|
||||
};
|
||||
@ -19,7 +19,7 @@ const PACKAGE_JSON_PATH = function() {
|
||||
return path.resolve(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'react-native-pushy',
|
||||
'react-native-update',
|
||||
'package.json'
|
||||
);
|
||||
};
|
||||
@ -36,17 +36,17 @@ if (cli) {
|
||||
cli.run();
|
||||
} else {
|
||||
console.error('Are you at home directory of a react-native project?');
|
||||
console.error('`pushy install` is under development, please run `npm install react-native-pushy` to install pushy manually.');
|
||||
console.error('`pushy install` is under development, please run `npm install react-native-update` to install pushy manually.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function checkForVersionCommand() {
|
||||
if (process.argv.indexOf('-v') >= 0 || process.argv[2] === 'version') {
|
||||
console.log('react-native-pushy-cli: ' + require('../package.json').version);
|
||||
console.log('react-native-update-cli: ' + require('../package.json').version);
|
||||
try {
|
||||
console.log('react-native-pushy: ' + require(PACKAGE_JSON_PATH()).version);
|
||||
console.log('react-native-update: ' + require(PACKAGE_JSON_PATH()).version);
|
||||
} catch (e) {
|
||||
console.log('react-native-pushy: n/a - not inside a React Native project directory')
|
||||
console.log('react-native-update: n/a - not inside a React Native project directory')
|
||||
}
|
||||
process.exit();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user