پیاده‌سازی صدور مجوز در کد جاوا

Myket licensing چیست؟

یکی از دغدغه‌های توسعه‌دهندگانی که می‌خواهند برنامه‌ی خود را به فروش برسانند، استفاده غیر مجاز از برنامه است. بعضی از افراد بدون اینکه برنامه را خریداری کنند از طریق منابع غیر رسمی (سایت‌ها و …) به فایل apk برنامه دسترسی پیدا می‌کنند.

مایکت از نسخه ۵.۲.۲، با اضافه کردن قابلیت Myket licensing می‌تواند جلوی این دسته افراد را بگیرد. برای این کار کافی است که با استفاده از کد‌های کمکی صدور مجوز مایکت (که در ادامه توضیح داده می‌شود)، از سرویس مایکت بپرسید که آیا کاربر اجازه استفاده از این برنامه را دارد یا خیر؟

در این مستند نحوه پیاده‌سازی و تست Myket licensing را به صورت گام به گام شرح می‌دهیم. برای اینکه مراحل کار را به صورت عملی نیز نشان داده باشیم، یک برنامه انتخاب کردیم تا هر گام را روی آن اعمال کنیم. برنامه‌ی استفاده شده بازی متن‌باز 2048 است.

گام اول: دریافت کلید رمز عمومی از پنل توسعه‌دهندگان

جهت استفاده از Myket licensing ابتدا نیاز است تا کلید رمز عمومی برنامه‌ی خود را از مایکت دریافت کنید. برای این کار باید یک نسخه از apk برنامهٔ خود را به پنل توسعه‌دهندگان اضافه کنید. سپس در قسمت برنامه ها در کادر برنامه، دکمه «مشاهده‌ٔ کلید عمومی» را بزنید تا کلید عمومی برنامهٔ شما در دیالوگی نمایش داده شود. این کلید در گام دوم قسمت ۴ استفاده می‌شود.

توجه کنید که Licensing برای برنامه‌های پولی قابل استفاده است. در صورتی که برنامهٔ‌ شما رایگان باشد نمی‌توانید از Myket licensing استفاده کنید.

گام دوم: پیاده‌سازی Myket licensing

در این بخش Myket licensing را برای برنامه‌ی متن‌باز 2048 پیاده‌سازی می‌کنیم. برای این کار به ترتیب زیر عمل می‌کنیم:

۱. ابتدا سورس برنامه را از قسمت پایین دانلود و محتویات پوشهٔ java را در پوشه‌ٔ src/main/java پروژهٔ خود کپی می‌کنیم.

سورس بازی 2048 تغییر یافته را می‌توانید از اینجا دانلود کنید.

ساختار تمام پروژه‌های اندرویدی یکسان نیست. در پروژه‌هایی که با Android Studio ایجاد شده‌اند، باید فایل‌های AIDL را در پوشه‌ی java/aidl کپی داد. ولی در ساختار‌های قدیمی مانند پروژه‌هایی که با Eclipse ایجاد شده‌اند، باید فایل‌های AIDL را در آدرس خودش (پکیجی که در خود فایل آورده شده است) و در کنار دیگر فایل‌های Java کپی کرد. پروژه‌ی 2048 ساختار قدیمی دارد و باید فایل‌های AIDL را به پوشه‌ی زیر منتقل کرد:

src/com/android/vending/licensing

۲. در فایل AndroidManifest.xml، مجوز برای دسترسی به سرویس Myket licensing را اضافه می‌کنیم:

<uses-permission android:name="ir.mservices.market.CHECK_LICENSE" />

۳. پرو‌‌ژه رو Rebuild می‌کنیم. توجه کنید در صورتی که از ابزار gradle استفاده می‌کنید باید gradle را Sync کنید تا فایل‌های AIDL‌ را Compile کند.

۴. برای بررسی license برنامه، باید یک CallBack از نوع LicenseCheckerCallback و یک LicenseChecker ایجاد کنیم:

private class MyLicenseCheckerCallback implements LicenseCheckerCallback {
    public void allow(int policyReason) {
    }
     
    public void dontAllow(int policyReason) {
    }
 
    public void applicationError(int errorCode) {
    }
}
 
// Library calls this when it's done.
mLicenseCheckerCallback = new MyLicenseCheckerCallback();
// Construct the LicenseChecker with a policy.
mChecker = new LicenseChecker(getApplicationContext(), policy, BASE64_PUBLIC_KEY);

کلاس LicenseChecker نیاز به سه پارامتر context، policy و publicKey دارد.

context: با استفاده از متد getApplicationContext، کانتکست برنامه را می‌فرستیم.

publicKey: کلید رمز عمومی که از پنل توسعه‌دهندگان دریافت کردیم را به پارامتر publicKey می‌دهیم. (گام اول)

policy: سیاست و استراتژی شما برای بررسی مجوز کاربران در قالب کلاسی با نام Policy تعریف می‌شود. پیشنهاد می‌شود که از کلاس MyketServerManagedPolicy برای Policy برنامه خود استفاده کنید. جهت ساختن یک نمونه از این کلاس به صورت زیر عمل می‌کنیم:

// Generate your own 20 random bytes, and put them here.
private static final byte[] SALT = new byte[]{
        -46, 65, 30, -128, -103, -57, 74, -64, 51, 88, -95, -45, 77, -117, -36, -113, -11, 32, -64, 89
};
  
// Try to use more data here. ANDROID_ID is a single point of attack.
String deviceId = Secure.getString(getContentResolver(), Secure.ANDROID_ID);
  
// Construct the LicenseChecker with a policy.
MyketServerManagedPolicy policy = new MyketServerManagedPolicy(getApplicationContext(),
        new AESObfuscator(SALT, getPackageName(), deviceId));

این کلاس به Context و AESObfuscator نیاز دارد. AESObfuscator برای ناخوانا کردن کد و بالا بردن امنیت به کار می‌رود. AESObfuscator به وسیله‌‌ی آرایه‌ای ۲۰ بایتی که به صورت تصادفی ایجاد شده است (SALT)، به همراه Package Name برنامه، و یک عدد یکتا از دستگاه کاربر ساخته می‌شود.

برای ساختن یک Instance از LicenseChecker به موارد زیر نیاز دارید:

deviceId: رشته یکتایی برای دستگاه استفاده کننده از برنامه شما.

SALT: شامل یک byte[] که حاوی ۲۰ عدد Random، جهت بالا رفتن امنیت پروتکل Licensing است.

BASE64_PUBLIC_KEY: کلید عمومی برنامه شما که از پنل توسعه‌دهندگان مایکت دریافت نموده‌اید.

نتیجه را در یک متد با نام initLicense قرار می‌دهیم:

private static final String BASE64_PUBLIC_KEY = "YOUR PUBLIC KEY";
  
// Generate your own 20 random bytes, and put them here.
private static final byte[] SALT = new byte[]{
        -46, 65, 30, -128, -103, -57, 74, -64, 51, 88, -95, -45, 77, -117, -36, -113, -11, 32, -64, 89
};
  
private LicenseCheckerCallback mLicenseCheckerCallback;
private LicenseChecker mChecker;
  
  
private void initLicense() {
    // Try to use more data here. ANDROID_ID is a single point of attack.
    String deviceId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
    // Library calls this when it's done.
    mLicenseCheckerCallback = new MyLicenseCheckerCallback();
    mHandler = new Handler();
    // Construct the LicenseChecker with a policy.
    MyketServerManagedPolicy policy = new MyketServerManagedPolicy(getApplicationContext(),
            new AESObfuscator(SALT, getPackageName(), deviceId));
    mChecker = new LicenseChecker(this, policy, BASE64_PUBLIC_KEY);
}
 
 
private class MyLicenseCheckerCallback implements LicenseCheckerCallback {
    public void allow(int policyReason) {
    }
    public void dontAllow(int policyReason) {
    }
    public void applicationError(int errorCode) {
    }
}

۵. همانطور که مشاهده می‌کنید در متد initLicense یک LicenseCheckerCallback  و یک LicenseChecker می‌سازیم. حال کافی است که متد checkAccess(LicenseCheckerCallback) از کلاس LicenseChecker را فراخوانی کنیم. تا پس از اتمام چک کردن License با سرور مایکت، یکی از متد‌های LicenseCheckerCallback  صدا زده شود. برای این کار متدی با نام doCheck ایجاد می‌کنیم:

private void doCheck() {
    setProgressBarIndeterminateVisibility(true);
    mChecker.checkAccess(mLicenseCheckerCallback);
} 

در صورتی که متد allow‌ صدا زده شد کاربر می‌تواند از برنامه استفاده کند در غیر این صورت (زمانی که dontAllow یا applicationError صدا زده شد) کاربر مجاز به استفاده از برنامه نیست و باید خطای مورد نظر نمایش دهیم.

۶. برای پیاده‌سازی Myket license در برنامه متن‌باز 2048 کافی است کمی تغییر در کلاس MainActivity ایجاد کنیم.

ابتدا از متد onCreate برنامه متن‌باز 2048  تکه کدی را که باعث می‌شود برنامه اجرا شود را به متدی با نام appOnCreate منتقل می‌کنیم. سپس به جای تکه کد جدا شده متد initLicense را فراخوانی کرده و برای جلوگیری از چک کردن دوباره License در حین اجرا، یک فیلد Boolean با نام isLicenseCheck تعریف می‌کنیم و با شرط false بودن این متغییر متد initLicense را صدا می‌زنیم. در غیر این صورت متد appOnCreate را Call می‌کنیم.

 در نهایت متد onCreate برنامه به صورت زیر می‌شود:

@SuppressLint({"SetJavaScriptEnabled", "NewApi", "ShowToast"})
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // Don't show an action bar or title
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    // If on android 3.0+ activate hardware acceleration
    if (Build.VERSION.SDK_INT >= 11) {
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
                WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
    }
    // Apply previous setting about showing status bar or not
    applyFullScreen(isFullScreen());
    // Check if screen rotation is locked in settings
    boolean isOrientationEnabled = false;
    try {
        isOrientationEnabled = Settings.System.getInt(getContentResolver(),
                Settings.System.ACCELEROMETER_ROTATION) == 1;
    } catch (SettingNotFoundException e) {
    }
    // If rotation isn't locked and it's a LARGE screen then add orientation changes based on sensor
    int screenLayout = getResources().getConfiguration().screenLayout
            &amp; Configuration.SCREENLAYOUT_SIZE_MASK;
    if (((screenLayout == Configuration.SCREENLAYOUT_SIZE_LARGE)
            || (screenLayout == Configuration.SCREENLAYOUT_SIZE_XLARGE))
            &amp;&amp; isOrientationEnabled) {
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR);
    }
    setContentView(R.layout.activity_main);
    // Load webview with game
    mWebView = (WebView) findViewById(R.id.mainWebView);
    WebSettings settings = mWebView.getSettings();
    String packageName = getPackageName();
    settings.setJavaScriptEnabled(true);
    settings.setDomStorageEnabled(true);
    settings.setDatabaseEnabled(true);
    settings.setRenderPriority(RenderPriority.HIGH);
    settings.setDatabasePath("/data/data/" + packageName + "/databases");
    if (!isLicenseCheck) {
        initLicense();
        doCheck();
    } else {
        appOnCreate(savedInstanceState);
    }
}

۷. حالا کافی است بدنه متد‌های MyLicenseCheckerCallback را کامل کنیم. در متد allow باید به کاربر اجازه دسترسی به برنامه را بدهیم. یعنی کافی است متد appOnCreate را صدا بزنیم. در غیر این صورت در متد‌های dontAllow و applicationError کاربر اجازه استفاده از برنامه را ندارد ولی باید خطای به وجود آمده را مشاهده کند.

متد dontAllow یک پارامتر Integer با نام policyReason دارد که می‌تواند برابر مقادیر زیر باشد:

Policy.RETRY: در این مورد نیاز است به مجددا برای گرفتن License سعی نماییم.

Policy.MYKET_NOT_INSTALLED: زمانی که مایکت روی دستگاه کاربر نصب نباشد. در این صورت باید کاربر را به سایت مایکت بفرستیم تا ابتدا مایکت را دانلود و نصب کند، سپس مجددا اقدام کند.

Policy.MYKET_NOT_SUPPORTED: کاربر مایکت را روی دستگاه خود دارد، ولی نسخه آن قدیمی است. در این صورت باید به کاربر صفحه جزیئات مایکت را نمایش دهیم تا مایکت خود را به‌روز کند و مجددا سعی کند.

متد applicationError یک پارامتر Integer با نام errorCode دارد که خطاهای هنگام توسعه را بر‌می‌گرداند:

ERROR_INVALID_PACKAGE_NAME: برنامه شما در سرور مایکت وجود ندارد یا Package name شما غلط است.

ERROR_NON_MATCHING_UID: برنامه شما اجازه دسترسی به سرویس Myket licensing را ندارد.

ERROR_NOT_MARKET_MANAGED: سرور مایکت در حال حاظر قادر به پاسخگویی نیست.

ERROR_CHECK_IN_PROGRESS: در حال حاضر یک درخواست شما در حال پردازش است.

ERROR_INVALID_PUBLIC_KEY: کلید رمز عمومی شما غلط است.

ERROR_MISSING_PERMISSION: شما Myket licensing permission را در AndroidManifest تعریف نکرده‌اید.

توجه کنید که متد‌های LicenseCheckerCallback در Main Thread صدا زده نمی‌شوند و حتما باید با استفاده از یک Handler عملیات آن‌ها را در Thread اصلی Post کرد:
private Handler mHandler;
// Run on UI thread
mHandler.post(new Runnable() {
	public void run() {
    		setProgressBarIndeterminateVisibility(false);
            appOnCreate(null);
    }
});

بنابراین MyLicenseCheckerCallback به صورت زیر تکمیل می‌کنیم:

// A handler on the UI thread.
private Handler mHandler;
private class MyLicenseCheckerCallback implements LicenseCheckerCallback {
    public void allow(int policyReason) {
        if (isFinishing()) {
            // Don't update UI if Activity is finishing.
            return;
        }
        // Run on UI thread
        mHandler.post(new Runnable() {
            public void run() {
                setProgressBarIndeterminateVisibility(false);
                appOnCreate(null);
            }
        });
    }
    public void dontAllow(final int policyReason) {
        if (isFinishing()) {
            // Don't update UI if Activity is finishing.
            return;
        }
        // Run on UI thread
        mHandler.post(new Runnable() {
            public void run() {
                setProgressBarIndeterminateVisibility(false);
                showMyDialog(policyReason);
            }
        });
    }
    public void applicationError(final int errorCode) {
        if (isFinishing()) {
            // Don't update UI if Activity is finishing.
            return;
        }
        // Run on UI thread
        mHandler.post(new Runnable() {
            public void run() {
                setProgressBarIndeterminateVisibility(false);
                String result = String.format(getString(R.string.application_error), errorCode);
                Toast.makeText(getApplicationContext(), result, Toast.LENGTH_SHORT).show();
            }
        });
    }
}
private void showMyDialog(final int reason) {
    String dialogBody, buttonMsg;
    DialogInterface.OnClickListener listener;
    switch (reason) {
        case Policy.RETRY:
            dialogBody = getResources().getString(R.string.unlicensed_dialog_retry_body);
            buttonMsg = getResources().getString(R.string.retry_button);
            listener = new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    doCheck();
                }
            };
            break;
        case Policy.MYKET_NOT_INSTALLED:
            dialogBody = getResources().getString(R.string.unlicensed_dialog_download_myket_body);
            buttonMsg = getResources().getString(R.string.download_myket_button);
            listener = new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://myket.ir")));
                }
            };
            break;
        case Policy.MYKET_NOT_SUPPORTED:
            dialogBody = getResources().getString(R.string.unlicensed_dialog_update_myket_body);
            buttonMsg = getResources().getString(R.string.update_myket_button);
            listener = new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(
                            "myket://application/#Intent;scheme=myket;package="
                                    + LicenseChecker.MYKET_PACKAGE_NAME + ";end"));
                    startActivity(intent);
                }
            };
            break;
        default:
            dialogBody = getResources().getString(R.string.unlicensed_dialog_body);
            buttonMsg = getResources().getString(R.string.buy_button);
            listener = new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(
                            "myket://application/#Intent;scheme=myket;package="
                                    + getPackageName() + ";end"));
                    startActivity(intent);
                }
            };
            break;
    }
    new AlertDialog.Builder(this)
            .setTitle(R.string.unlicensed_dialog_title)
            .setMessage(dialogBody)
            .setPositiveButton(buttonMsg, listener)
            .setNegativeButton(R.string.quit_button, new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int which) {
                    finish();
                }
            }).create().show();
}

توجه کنید که حتما قبل از انجام هر عملیاتی در متد‌های LicenseCheckerCallback چک کنید که activity در حال بسته شدن نباشد:
if (isFinishing()) {
	// Don't update UI if Activity is finishing.
	return;
}

۸. حالا رشته‌های زیر را به strings.xml اضافه می‌کنیم: 

<string name="application_error">خطای برنامه: %1$s</string>
<string name="retry_button">تلاش مجدد</string>
<string name="buy_button">خرید برنامه</string>
<string name="retry_button">تلاش مجدد</string>
<string name="quit_button">خروج</string>
<string name="download_myket_button">دانلود مایکت</string>
<string name="update_myket_button">به‌روزرسانی مایکت</string>
<string name="unlicensed_dialog_title">صدور مجوز</string>
<string name="unlicensed_dialog_body">شما مجوز استفاده از را ندارید. لطفا ابتدا برنامه را از مایکت خریداری نمایید</string>
<string name="unlicensed_dialog_update_myket_body">برای استفاده از این برنامه، باید برنامه‌ی مایکت خود را به‌روزرسانی کنید</string>
<string name="unlicensed_dialog_download_myket_body">برای استفاده از این برنامه، باید برنامه‌ی مایکت را روی دستگاه خود نصب کنید</string>
<string name="unlicensed_dialog_retry_body">مشکلی به وجود آمده است، اتصال دستگاه خود به اینترنت را بررسی کنید و مجددا تلاش نمایید</string>

۹. توجه کنید که در متد onDestroy برنامه باید LicenseChecker را صدا بزنیم:

@Override
protected void onDestroy() {
    super.onDestroy();
    mChecker.onDestroy();
}

۱۰. حالا می‌توانیم پروژه را Build و اجرا کنیم.

سورس بازی 2048 تغییر یافته را می‌توانید از اینجا دانلود کنید.

توجه کنید که برنامهٔ شما تنها برای نسخهٔ سازگار آخر License می‌گیرد و اگر ٰVersionCode نسخه‌ای که در پنل آپلود کرده‌اید با VersionCodeی که در دستگاه نصب کرده‌اید متفاوت باشد، به شما License نمی‌هد. در این صورت اگر پس از انتشار نسخهٔ اول نسخهٔ دوم را منتشر کنید، دیگر کاربران شما با نسخهٔ اول License نمی‌گیرند. در صورتی که نمی‌خواهید این سناریو به وجود آید در کلاس LicenseValidator و متد verify، شرط زیر را حذف یا کامنت کنید:
if (!data.versionCode.equals(mVersionCode)) {
    Log.e(TAG, "Version codes don't match.");
    handleInvalidResponse();
    return;
}
توجه کنید که در صورتی که ساعت دستگاه کاربر شما (یا دستگاهی که شما با آن در حال تست هستید) با ساعت سرور مایکت اختلاف داشته باشد (تنظیم نباشد) License به شما داده نمی‌شود. اگر نمی‌خواهید این محدودیت را ایجاد کنید در کلاس LicenseChecker و متد checkAccess در خط زیر به جای DeviceTimeLimiter از DeviceLimiter استفاده کنید:
LicenseValidator validator = new LicenseValidator(mPolicy, new DeviceTimeLimiter(),
        callback, generateNonce(), mPackageName, mVersionCode);

توجه کنید برای اولین اجرا نیاز است که دستگاه کاربر به اینترنت متصل باشد. زمانی که کابر برنامه را از مایکت دریافت کرد و وارد برنامه شد و مجوز گرفت، کدهای کمکی مایکت در صورتی که در کلاس LicenseChecker از MyketServerManagedPolicy به عنوان policy برنامهٔ خود استفاده کنید، مجوز را cache می‌شود و کاربر می‌تواند از برنامه استفاده کند. همچنین اولین بار مدت زمان این cache برابر با زمانی‌ است که کاربر می‌تواند برنامه را پس دهد (بازگشت وجه) و پس از گذشت این زمان، روی دستگاه برای همیشه cache می‌شود (تا زمانی که دیتای برنامهٔ شما پاک و یا برنامه Uninstall نشود). البته در صورتی که از StrictPolicy استفاده کنید، برنامهٔ شما cache نخواهد داشت و هر بار، با باز شدن از سرور مایکت مجوز کاربر مورد نظر را سوال می‌کند. توجه کنید که در این صورت برای استفاده از برنامهٔ شما، کاربر باید به اینترنت متصل باشد.توصیهٔ امنیتی: برای جلوگیری از عملکرد برنامه‌های مخرب، حتما از سورس کد برنامهٔ خود محافظت کنید. ابتدا باید در کد خود بهم‌ریختگی (obfuscation) ایجاد کنید. برای این کار می‌توانید از ابزار‌هایی نظیر ProGuard استفاده کنید. همچنین مقادیر حساس نظیر SALT و کلید رمز عمومی (Public key) را به صورت رشته‌ای ثابت در کد خود قرار ندهید و سعی کنید این رشته‌ها را در زمان اجرای برنامه بسازید (مانند XOR با چند رشتهٔ دیگر). توجه کنید سورس کد‌ کمکی همچنان که در اختیار شما قرار دارد می‌تواند در اختیار هکر‌ها نیز قرار گیرد بنابراین بهتر است ساختار پروژه و ترتیب متد‌ها را تغییر دهید تا برنامهٔ شما الگوی متفاوتی داشته باشد و پیدا کردن آن زمان‌بر شود.

گام سوم: تست برنامه

برای تست سناریوی Myket licensing قبل از انشار دو پیش‌نیاز وجود دارد:

۱. مایکت (نسخه بالای ۵.۲.۲) روی دستگاه نصب باشد.

۲. با اکانت خود (اکانتی که با آن وارد پنل توسعه‌دهندگان می‌شوید) در مایکت Login کنید. توجه کنید در صورتی که با اکانت دیگری وارد مایکت شویم، دیگر مجاز به استفاده از برنامه نیستیم.

در پنل توسعه‌دهندگان قسمتی برای تست سناریوی LICENSED و NOT_LICENSED در نظر گرفته شده است. برای این کار در پنل توسعه‌دهندگان وارد صفحهٔ جزئیات برنامه‌ٔ خود شوید و در بخش اطلاعات پایه در کنار گزینه ویرایش دکمهٔ «بازگشت پول و صدور مجوز» را کلیک کنید:

حال در دیالوگ باز شده در قسمت «تست قابلیت صدور مجوز» در صورتی که گزینهٔ «اجازه دسترسی به برنامه را نداشته باشم» انتخاب شده باشد سرور مایکت به شما NOT_LICENSED می‌دهد و در صورتی که گزینهٔ «اجازه دسترسی به برنامه را داشته باشم» انتخاب شده باشد، سرور مایکت به شما LICENSED می‌دهد:

 این دو سناریو را با تغییر وضعیت این گزینه‌ها تست کنید و مطمئن شوید که مشکلی در پیاده‌سازی این سناریو‌ها وجود ندارد.

گام چهارم: تست کردن برنامه پس از انتشار و تایید مدیر

پس از اینکه برنامهٔ خود را برای تایید ارسال کردید و به مایکت اضافه شد، با حساب کاربری دیگری این سناریو‌ها رو مجدد تست کنید.

فایل های پیوست

Was this article helpful?
Dislike 0
قبلی: معرفی سرویس صدور‌ مجوز مایکت
بعدی: پیاده سازی صدور مجوز در Unity