Remove GodotPayment android plugin
This is now available in a separate repository at https://github.com/godotengine/godot-google-play-billing
This commit is contained in:
parent
492625db28
commit
83a966f0d8
7 changed files with 12 additions and 367 deletions
|
@ -97,13 +97,6 @@ task copyReleaseAARToAppModule(type: Copy) {
|
||||||
include('godot-lib.release.aar')
|
include('godot-lib.release.aar')
|
||||||
}
|
}
|
||||||
|
|
||||||
task copyGodotPaymentPluginToAppModule(type: Copy) {
|
|
||||||
dependsOn ':plugins:godotpayment:assembleRelease'
|
|
||||||
from('plugins/godotpayment/build/outputs/aar')
|
|
||||||
into('app/libs/plugins')
|
|
||||||
include('GodotPayment.release.aar')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy the Godot android library archive release file into the root bin directory.
|
* Copy the Godot android library archive release file into the root bin directory.
|
||||||
* Depends on the library build task to ensure the AAR file is generated prior to copying.
|
* Depends on the library build task to ensure the AAR file is generated prior to copying.
|
||||||
|
@ -161,7 +154,6 @@ task generateGodotTemplates(type: GradleBuild) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependsOn 'copyGodotPaymentPluginToAppModule'
|
|
||||||
finalizedBy 'zipCustomBuild'
|
finalizedBy 'zipCustomBuild'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
0
platform/android/java/plugins/.gitkeep
Normal file
0
platform/android/java/plugins/.gitkeep
Normal file
|
@ -1,32 +0,0 @@
|
||||||
apply plugin: 'com.android.library'
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdkVersion versions.compileSdk
|
|
||||||
buildToolsVersion versions.buildTools
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdkVersion versions.minSdk
|
|
||||||
targetSdkVersion versions.targetSdk
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryVariants.all { variant ->
|
|
||||||
variant.outputs.all { output ->
|
|
||||||
output.outputFileName = "GodotPayment.${variant.name}.aar"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation libraries.supportCoreUtils
|
|
||||||
implementation libraries.v4Support
|
|
||||||
implementation 'com.android.billingclient:billing:2.2.1'
|
|
||||||
|
|
||||||
if (rootProject.findProject(":lib")) {
|
|
||||||
compileOnly project(":lib")
|
|
||||||
} else if (rootProject.findProject(":godot:lib")) {
|
|
||||||
compileOnly project(":godot:lib")
|
|
||||||
} else {
|
|
||||||
compileOnly fileTree(dir: 'libs', include: ['godot-lib*.aar'])
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
package="org.godotengine.godot.plugin.payment">
|
|
||||||
|
|
||||||
<application>
|
|
||||||
|
|
||||||
<meta-data
|
|
||||||
android:name="org.godotengine.plugin.v1.GodotPayment"
|
|
||||||
android:value="org.godotengine.godot.plugin.payment.GodotPayment" />
|
|
||||||
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
|
@ -1,239 +0,0 @@
|
||||||
/*************************************************************************/
|
|
||||||
/* GodotPayment.java */
|
|
||||||
/*************************************************************************/
|
|
||||||
/* This file is part of: */
|
|
||||||
/* GODOT ENGINE */
|
|
||||||
/* https://godotengine.org */
|
|
||||||
/*************************************************************************/
|
|
||||||
/* Copyright (c) 2007-2020 Juan Linietsky, Ariel Manzur. */
|
|
||||||
/* Copyright (c) 2014-2020 Godot Engine contributors (cf. AUTHORS.md). */
|
|
||||||
/* */
|
|
||||||
/* Permission is hereby granted, free of charge, to any person obtaining */
|
|
||||||
/* a copy of this software and associated documentation files (the */
|
|
||||||
/* "Software"), to deal in the Software without restriction, including */
|
|
||||||
/* without limitation the rights to use, copy, modify, merge, publish, */
|
|
||||||
/* distribute, sublicense, and/or sell copies of the Software, and to */
|
|
||||||
/* permit persons to whom the Software is furnished to do so, subject to */
|
|
||||||
/* the following conditions: */
|
|
||||||
/* */
|
|
||||||
/* The above copyright notice and this permission notice shall be */
|
|
||||||
/* included in all copies or substantial portions of the Software. */
|
|
||||||
/* */
|
|
||||||
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
|
|
||||||
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
|
|
||||||
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/
|
|
||||||
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
|
|
||||||
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
|
|
||||||
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
|
|
||||||
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
|
|
||||||
/*************************************************************************/
|
|
||||||
|
|
||||||
package org.godotengine.godot.plugin.payment;
|
|
||||||
|
|
||||||
import org.godotengine.godot.Dictionary;
|
|
||||||
import org.godotengine.godot.Godot;
|
|
||||||
import org.godotengine.godot.plugin.GodotPlugin;
|
|
||||||
import org.godotengine.godot.plugin.SignalInfo;
|
|
||||||
import org.godotengine.godot.plugin.payment.utils.GodotPaymentUtils;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.collection.ArraySet;
|
|
||||||
|
|
||||||
import com.android.billingclient.api.AcknowledgePurchaseParams;
|
|
||||||
import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
|
|
||||||
import com.android.billingclient.api.BillingClient;
|
|
||||||
import com.android.billingclient.api.BillingClientStateListener;
|
|
||||||
import com.android.billingclient.api.BillingFlowParams;
|
|
||||||
import com.android.billingclient.api.BillingResult;
|
|
||||||
import com.android.billingclient.api.ConsumeParams;
|
|
||||||
import com.android.billingclient.api.ConsumeResponseListener;
|
|
||||||
import com.android.billingclient.api.Purchase;
|
|
||||||
import com.android.billingclient.api.PurchasesUpdatedListener;
|
|
||||||
import com.android.billingclient.api.SkuDetails;
|
|
||||||
import com.android.billingclient.api.SkuDetailsParams;
|
|
||||||
import com.android.billingclient.api.SkuDetailsResponseListener;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public class GodotPayment extends GodotPlugin implements PurchasesUpdatedListener, BillingClientStateListener {
|
|
||||||
private final BillingClient billingClient;
|
|
||||||
private final HashMap<String, SkuDetails> skuDetailsCache = new HashMap<>(); // sku → SkuDetails
|
|
||||||
|
|
||||||
public GodotPayment(Godot godot) {
|
|
||||||
super(godot);
|
|
||||||
|
|
||||||
billingClient = BillingClient
|
|
||||||
.newBuilder(getActivity())
|
|
||||||
.enablePendingPurchases()
|
|
||||||
.setListener(this)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startConnection() {
|
|
||||||
billingClient.startConnection(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void endConnection() {
|
|
||||||
billingClient.endConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isReady() {
|
|
||||||
return this.billingClient.isReady();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Dictionary queryPurchases(String type) {
|
|
||||||
Purchase.PurchasesResult result = billingClient.queryPurchases(type);
|
|
||||||
|
|
||||||
Dictionary returnValue = new Dictionary();
|
|
||||||
if (result.getBillingResult().getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
returnValue.put("status", 0); // OK = 0
|
|
||||||
returnValue.put("purchases", GodotPaymentUtils.convertPurchaseListToDictionaryObjectArray(result.getPurchasesList()));
|
|
||||||
} else {
|
|
||||||
returnValue.put("status", 1); // FAILED = 1
|
|
||||||
returnValue.put("response_code", result.getBillingResult().getResponseCode());
|
|
||||||
returnValue.put("debug_message", result.getBillingResult().getDebugMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void querySkuDetails(final String[] list, String type) {
|
|
||||||
List<String> skuList = Arrays.asList(list);
|
|
||||||
|
|
||||||
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder()
|
|
||||||
.setSkusList(skuList)
|
|
||||||
.setType(type);
|
|
||||||
|
|
||||||
billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
|
|
||||||
@Override
|
|
||||||
public void onSkuDetailsResponse(BillingResult billingResult,
|
|
||||||
List<SkuDetails> skuDetailsList) {
|
|
||||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
for (SkuDetails skuDetails : skuDetailsList) {
|
|
||||||
skuDetailsCache.put(skuDetails.getSku(), skuDetails);
|
|
||||||
}
|
|
||||||
emitSignal("sku_details_query_completed", (Object)GodotPaymentUtils.convertSkuDetailsListToDictionaryObjectArray(skuDetailsList));
|
|
||||||
} else {
|
|
||||||
emitSignal("sku_details_query_error", billingResult.getResponseCode(), billingResult.getDebugMessage(), list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void acknowledgePurchase(final String purchaseToken) {
|
|
||||||
AcknowledgePurchaseParams acknowledgePurchaseParams =
|
|
||||||
AcknowledgePurchaseParams.newBuilder()
|
|
||||||
.setPurchaseToken(purchaseToken)
|
|
||||||
.build();
|
|
||||||
billingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
|
|
||||||
@Override
|
|
||||||
public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
|
|
||||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
emitSignal("purchase_acknowledged", purchaseToken);
|
|
||||||
} else {
|
|
||||||
emitSignal("purchase_acknowledgement_error", billingResult.getResponseCode(), billingResult.getDebugMessage(), purchaseToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void consumePurchase(String purchaseToken) {
|
|
||||||
ConsumeParams consumeParams = ConsumeParams.newBuilder()
|
|
||||||
.setPurchaseToken(purchaseToken)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
billingClient.consumeAsync(consumeParams, new ConsumeResponseListener() {
|
|
||||||
@Override
|
|
||||||
public void onConsumeResponse(BillingResult billingResult, String purchaseToken) {
|
|
||||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
emitSignal("purchase_consumed", purchaseToken);
|
|
||||||
} else {
|
|
||||||
emitSignal("purchase_consumption_error", billingResult.getResponseCode(), billingResult.getDebugMessage(), purchaseToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBillingSetupFinished(BillingResult billingResult) {
|
|
||||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
emitSignal("connected");
|
|
||||||
} else {
|
|
||||||
emitSignal("connect_error", billingResult.getResponseCode(), billingResult.getDebugMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBillingServiceDisconnected() {
|
|
||||||
emitSignal("disconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Dictionary purchase(String sku) {
|
|
||||||
if (!skuDetailsCache.containsKey(sku)) {
|
|
||||||
emitSignal("purchase_error", null, "You must query the sku details and wait for the result before purchasing!");
|
|
||||||
}
|
|
||||||
|
|
||||||
SkuDetails skuDetails = skuDetailsCache.get(sku);
|
|
||||||
BillingFlowParams purchaseParams = BillingFlowParams.newBuilder()
|
|
||||||
.setSkuDetails(skuDetails)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
BillingResult result = billingClient.launchBillingFlow(getActivity(), purchaseParams);
|
|
||||||
|
|
||||||
Dictionary returnValue = new Dictionary();
|
|
||||||
if (result.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
|
||||||
returnValue.put("status", 0); // OK = 0
|
|
||||||
} else {
|
|
||||||
returnValue.put("status", 1); // FAILED = 1
|
|
||||||
returnValue.put("response_code", result.getResponseCode());
|
|
||||||
returnValue.put("debug_message", result.getDebugMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPurchasesUpdated(final BillingResult billingResult, @Nullable final List<Purchase> list) {
|
|
||||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && list != null) {
|
|
||||||
emitSignal("purchases_updated", (Object)GodotPaymentUtils.convertPurchaseListToDictionaryObjectArray(list));
|
|
||||||
} else {
|
|
||||||
emitSignal("purchase_error", billingResult.getResponseCode(), billingResult.getDebugMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public String getPluginName() {
|
|
||||||
return "GodotPayment";
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public List<String> getPluginMethods() {
|
|
||||||
return Arrays.asList("startConnection", "endConnection", "purchase", "querySkuDetails", "isReady", "queryPurchases", "acknowledgePurchase", "consumePurchase");
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public Set<SignalInfo> getPluginSignals() {
|
|
||||||
Set<SignalInfo> signals = new ArraySet<>();
|
|
||||||
|
|
||||||
signals.add(new SignalInfo("connected"));
|
|
||||||
signals.add(new SignalInfo("disconnected"));
|
|
||||||
signals.add(new SignalInfo("connect_error", Integer.class, String.class));
|
|
||||||
signals.add(new SignalInfo("purchases_updated", Object[].class));
|
|
||||||
signals.add(new SignalInfo("purchase_error", Integer.class, String.class));
|
|
||||||
signals.add(new SignalInfo("sku_details_query_completed", Object[].class));
|
|
||||||
signals.add(new SignalInfo("sku_details_query_error", Integer.class, String.class, String[].class));
|
|
||||||
signals.add(new SignalInfo("purchase_acknowledged", String.class));
|
|
||||||
signals.add(new SignalInfo("purchase_acknowledgement_error", Integer.class, String.class, String.class));
|
|
||||||
signals.add(new SignalInfo("purchase_consumed", String.class));
|
|
||||||
signals.add(new SignalInfo("purchase_consumption_error", Integer.class, String.class, String.class));
|
|
||||||
|
|
||||||
return signals;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package org.godotengine.godot.plugin.payment.utils;
|
|
||||||
|
|
||||||
import org.godotengine.godot.Dictionary;
|
|
||||||
|
|
||||||
import com.android.billingclient.api.Purchase;
|
|
||||||
import com.android.billingclient.api.SkuDetails;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class GodotPaymentUtils {
|
|
||||||
public static Dictionary convertPurchaseToDictionary(Purchase purchase) {
|
|
||||||
Dictionary dictionary = new Dictionary();
|
|
||||||
dictionary.put("order_id", purchase.getOrderId());
|
|
||||||
dictionary.put("package_name", purchase.getPackageName());
|
|
||||||
dictionary.put("purchase_state", Integer.valueOf(purchase.getPurchaseState()));
|
|
||||||
dictionary.put("purchase_time", Long.valueOf(purchase.getPurchaseTime()));
|
|
||||||
dictionary.put("purchase_token", purchase.getPurchaseToken());
|
|
||||||
dictionary.put("signature", purchase.getSignature());
|
|
||||||
dictionary.put("sku", purchase.getSku());
|
|
||||||
dictionary.put("is_acknowledged", Boolean.valueOf(purchase.isAcknowledged()));
|
|
||||||
dictionary.put("is_auto_renewing", Boolean.valueOf(purchase.isAutoRenewing()));
|
|
||||||
return dictionary;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Dictionary convertSkuDetailsToDictionary(SkuDetails details) {
|
|
||||||
Dictionary dictionary = new Dictionary();
|
|
||||||
dictionary.put("sku", details.getSku());
|
|
||||||
dictionary.put("title", details.getTitle());
|
|
||||||
dictionary.put("description", details.getDescription());
|
|
||||||
dictionary.put("price", details.getPrice());
|
|
||||||
dictionary.put("price_currency_code", details.getPriceCurrencyCode());
|
|
||||||
dictionary.put("price_amount_micros", Long.valueOf(details.getPriceAmountMicros()));
|
|
||||||
dictionary.put("free_trial_period", details.getFreeTrialPeriod());
|
|
||||||
dictionary.put("icon_url", details.getIconUrl());
|
|
||||||
dictionary.put("introductory_price", details.getIntroductoryPrice());
|
|
||||||
dictionary.put("introductory_price_amount_micros", Long.valueOf(details.getIntroductoryPriceAmountMicros()));
|
|
||||||
dictionary.put("introductory_price_cycles", details.getIntroductoryPriceCycles());
|
|
||||||
dictionary.put("introductory_price_period", details.getIntroductoryPricePeriod());
|
|
||||||
dictionary.put("original_price", details.getOriginalPrice());
|
|
||||||
dictionary.put("original_price_amount_micros", Long.valueOf(details.getOriginalPriceAmountMicros()));
|
|
||||||
dictionary.put("subscription_period", details.getSubscriptionPeriod());
|
|
||||||
dictionary.put("type", details.getType());
|
|
||||||
dictionary.put("is_rewarded", Boolean.valueOf(details.isRewarded()));
|
|
||||||
return dictionary;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Object[] convertPurchaseListToDictionaryObjectArray(List<Purchase> purchases) {
|
|
||||||
Object[] purchaseDictionaries = new Object[purchases.size()];
|
|
||||||
|
|
||||||
for (int i = 0; i < purchases.size(); i++) {
|
|
||||||
purchaseDictionaries[i] = GodotPaymentUtils.convertPurchaseToDictionary(purchases.get(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
return purchaseDictionaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Object[] convertSkuDetailsListToDictionaryObjectArray(List<SkuDetails> skuDetails) {
|
|
||||||
Object[] skuDetailsDictionaries = new Object[skuDetails.size()];
|
|
||||||
|
|
||||||
for (int i = 0; i < skuDetails.size(); i++) {
|
|
||||||
skuDetailsDictionaries[i] = GodotPaymentUtils.convertSkuDetailsToDictionary(skuDetails.get(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
return skuDetailsDictionaries;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -86,17 +86,18 @@ struct PluginConfig {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set of prebuilt plugins.
|
* Set of prebuilt plugins.
|
||||||
|
* Currently unused, this is just for future reference:
|
||||||
*/
|
*/
|
||||||
static const PluginConfig GODOT_PAYMENT = {
|
// static const PluginConfig MY_PREBUILT_PLUGIN = {
|
||||||
/*.valid_config =*/true,
|
// /*.valid_config =*/true,
|
||||||
/*.last_updated =*/0,
|
// /*.last_updated =*/0,
|
||||||
/*.name =*/"GodotPayment",
|
// /*.name =*/"GodotPayment",
|
||||||
/*.binary_type =*/"local",
|
// /*.binary_type =*/"local",
|
||||||
/*.binary =*/"res://android/build/libs/plugins/GodotPayment.release.aar",
|
// /*.binary =*/"res://android/build/libs/plugins/GodotPayment.release.aar",
|
||||||
/*.local_dependencies =*/{},
|
// /*.local_dependencies =*/{},
|
||||||
/*.remote_dependencies =*/String("com.android.billingclient:billing:2.2.1").split("|"),
|
// /*.remote_dependencies =*/String("com.android.billingclient:billing:2.2.1").split("|"),
|
||||||
/*.custom_maven_repos =*/{}
|
// /*.custom_maven_repos =*/{}
|
||||||
};
|
// };
|
||||||
|
|
||||||
static inline String resolve_local_dependency_path(String plugin_config_dir, String dependency_path) {
|
static inline String resolve_local_dependency_path(String plugin_config_dir, String dependency_path) {
|
||||||
String absolute_path;
|
String absolute_path;
|
||||||
|
@ -125,7 +126,7 @@ static inline PluginConfig resolve_prebuilt_plugin(PluginConfig prebuilt_plugin,
|
||||||
|
|
||||||
static inline Vector<PluginConfig> get_prebuilt_plugins(String plugins_base_dir) {
|
static inline Vector<PluginConfig> get_prebuilt_plugins(String plugins_base_dir) {
|
||||||
Vector<PluginConfig> prebuilt_plugins;
|
Vector<PluginConfig> prebuilt_plugins;
|
||||||
prebuilt_plugins.push_back(resolve_prebuilt_plugin(GODOT_PAYMENT, plugins_base_dir));
|
// prebuilt_plugins.push_back(resolve_prebuilt_plugin(MY_PREBUILT_PLUGIN, plugins_base_dir));
|
||||||
return prebuilt_plugins;
|
return prebuilt_plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue