Unity No-Backend OSP

1. Create the C# Script

To implement OSP, create a script that contains the logic and can be added to the IAP buttons. Firstly create 2 dynamic and 2 static values (productid, value, and domain, currency), this way every button will have the domain and currency to build the URL, but each button can have a different product and value associated.

public string productId;
public float value;
 
private static string DOMAIN = "com.mygamestudio.game";
private static string CURRENCY = "USD";

Then create a function that starts the OSP purchase flow and set it as the onClick listener for the associated button. To avoid repetition you can also store the Unity activity as a field and instantiate it on Start

//This two fields will be necessary later
private AndroidJavaObject activity;
private static int MATCH_DEFAULT_ONLY_ANDROID_PM =  65536;
 
void Start ()
{
       var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
       activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
       GetComponent<Button>().onClick.AddListener(TaskOnClick);
}


void TaskOnClick()
{
       var url = CreateURL();
       var ospActivity = new AndroidJavaClass("com.appcoins.osp.OspActivity");
       ospActivity.CallStatic("start", activity, url);
}

2. Create URL

The first step is to add the functionality to your app to generate the URL as specified in OSP URL Structure.
The domain must be the package name and it must be certified in catappult even for testing purposes.

To next example shows how to create the URL:

string CreateURL() 
{
       return [email protected]"https://apichain.catappult.io/transaction/inapp
            ?value={value}
            &product={productId}
            &domain={DOMAIN}
            &currency={CURRENCY}";
}

3. Create Osp Activity

To validate a transaction client-side it is needed to have an onActivityResult, and for this, it is necessary to create an Activity whose sole purpose will be to handle the call of the intent and the validation, this has to be done in Java/Kotlin.

Go to Assets/Plugins and create OspActivity and add the following code:

package com.appcoins.osp
 
class OspActivity : Activity() {
   companion object {
       private val TAG = OspActivity::class.java.simpleName
       private const val REQUEST_CODE = 1234
       lateinit var url: String
 
       @JvmStatic
       fun start(context: Activity, url: String) {
           Log.d(TAG, "$context && $url")
           OspActivity.url = url
           val starter = Intent(context, OspActivity::class.java)
           context.startActivity(starter)
       }
   }
}
package com.appcoins.osp;

public class OSPActivity extends Activity {
    private final static String TAG = OSPActivity.class.getSimpleName();
    private final static int REQUEST_CODE = 1234;
    private static String url;

    public static void start(Activity context, String url) {
        Log.d(TAG, context.toString() + " && " + url);
        OSPActivity.url = url;
        Intent starter = new Intent(context, OSPActivity2.class);
        context.startActivity(starter);
    }
}

The method start is the one called from the C# scripts and handles the transfer of data from the script to the Osp Activity, and starts itself.

To make the Activity available to Android go to the Unity AndroidManifest and add this activity:

<activity android:name="com.appcoins.osp.OspActivity"
                    android:theme="@android:style/Theme.Translucent.NoTitleBar">
</activity>

If you don’t know how to access Unity’s AndroidManifest go to File > Build Settings… > Player Settings… > Publishing Settings, and tick the option Build > Custom Main Manifest, this will add an AndroidManifest template that will be used by Unity.

4. Create and Start Intent

Inside the app/game, an intent with the URL should be opened either using the web browser (whenever the AppCoins Wallet is not installed), or via the AppCoins Wallet.

In some cases, the user chooses to open the URL with the browser when the AppCoins Wallet is already installed or even chooses the option to always open the URL with the browser. To avoid this add the following sample to trigger the One Step billing flow.

override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val intent = buildTargetIntent(url)
       startActivityForResult(intent, REQUEST_CODE)
}
 
/**
* This method generates the intent with the provided One Step URL to target the
* AppCoins Wallet.
* @param url The url that generated by following the One Step payment rules
*
* @return The intent used to call the wallet
*/
private fun buildTargetIntent(url: String): Intent {
       val intent = Intent(Intent.ACTION_VIEW)
       intent.data = Uri.parse(url)
 
       // Check if there is an application that can process the AppCoins Billing
       // flow
       val packageManager = applicationContext.packageManager
       val appsList: List<ResolveInfo> = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
       for (app in appsList) {
           if (app.activityInfo.packageName == "cm.aptoide.pt") {
               // If there's aptoide installed always choose Aptoide as default to open
               // url
               intent.setPackage(app.activityInfo.packageName)
               break
           } else if (app.activityInfo.packageName == "com.appcoins.wallet") {
               // If Aptoide is not installed and wallet is installed then choose Wallet
               // as default to open url
               intent.setPackage(app.activityInfo.packageName)
           }
       }
       return intent
}
@Override
protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = buildTargetIntent(url);
        startActivityForResult(intent, REQUEST_CODE);
}

/**
* This method generates the intent with the provided One Step URL to target the
* AppCoins Wallet.
* @param url The url that generated by following the One Step payment rules
*
* @return The intent used to call the wallet
*/
private Intent buildTargetIntent(String url) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setData(Uri.parse(url));

        // Check if there is an application that can process the AppCoins Billing
        // flow
        PackageManager packageManager = getApplicationContext().getPackageManager();
        List<ResolveInfo> appsList = packageManager
                .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
        for (ResolveInfo app : appsList) {
            if (app.activityInfo.packageName.equals("cm.aptoide.pt")) {
                // If there's aptoide installed always choose Aptoide as default to open
                // url
                intent.setPackage(app.activityInfo.packageName);
                break;
            } else if (app.activityInfo.packageName.equals("com.appcoins.wallet")) {
                // If Aptoide is not installed and wallet is installed then choose Wallet
                // as default to open url
                intent.setPackage(app.activityInfo.packageName);
            }
        }
        return intent;
}

5. Receive data from the wallet

To validate the transaction you need to receive the transaction hash from the wallet, this data is received on Activity Result:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
       super.onActivityResult(requestCode, resultCode, data)
       if (requestCode == REQUEST_CODE) {
           if (data == null || data.extras == null) {
               finish()
               return
           }
           val transactionHash = data.getStringExtra("transaction_hash")
           val url = URL("https://api.catappult.io/broker/8.20200101/transactions?hash=$transactionHash")
           Thread {
               validateTransaction(url)
           }.start()
           Log.d(TAG, "$transactionHash")
           finish()
       }
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_CODE) {
            if (data == null || data.getExtras() == null) {
                finish();
                return;
            }
            String transactionHash = data.getStringExtra("transaction_hash");
            try {
                URL url = new URL("https://api.catappult.io/broker/8.20200101/transactions?hash=" + transactionHash);
                new Thread() {
                    public void run() {
                        try {
                            validateTransaction(url);
                        } catch (JSONException e) {
                            e.printStackTrace();
                        }
                    }
                }.start();
            } catch (MalformedURLException e) {
                e.printStackTrace();
            }
            Log.d(TAG, transactionHash);
            finish();
        }
}

6. Validate the transaction

To validate the transaction fetch the transaction data from Catappult's API and compare the data with the values in the OSP URL.
For this to work the currency in the OSP URL must be either USD or APPC.

import com.unity3d.player.UnityPlayer
 
private fun validateTransaction(url: URL) {
    val transaction: String = getTransactionData(url) ?: return
    val json = JSONObject(transaction)
    val transactionDetails = json.getJSONArray("items").getJSONObject(0)
    val ospUri = Uri.parse(OspActivity.url)
 
    val methodToCall = if (isValid(transactionDetails, ospUri)) "OnPurchaseSuccess" else "OnPurchaseUnsuccessful"
    // This method calls the method "methodToCall" belonging to the game object PurchaseManager, this game
    // object must be created and added a script that contains the possible methods that methodToCall contains and receive a string
    // With this it is possible to preform diferent functionalities in c# for when the validation fails or is successful
    UnityPlayer.UnitySendMessage(
        "PurchaseManager",
        methodToCall,
        ospUri.getQueryParameter("product")
    )
}

// Fetches the data from catappult's API
private fun getTransactionData(url: URL): String? {
    val urlConnection= url.openConnection() as HttpURLConnection
    return try {
        val content = urlConnection.inputStream.bufferedReader().readText()
        content
    } catch (e: Exception) {
        e.printStackTrace()
        null
    } finally {
        urlConnection.disconnect()
    }
}

// Compares the values received from catappult with the query parameters from the OSP URL
private fun isValid(transactionDetails: JSONObject, ospUri: Uri): Boolean {
    val priceDetails = transactionDetails.getJSONObject("price")
    val product = ospUri.getQueryParameter("product")
    val domain = ospUri.getQueryParameter("domain")
    val reference = ospUri.getQueryParameter("reference") ?: "null"
    val value = (ospUri.getQueryParameter("value")?.toDouble() ?: 0.0) as Double?
 
    return product == transactionDetails.getString("product")
                    && domain == transactionDetails.getString("domain")
                    && reference == transactionDetails.getString("reference")
                    && value == priceDetails.getDouble("usd")
}
import com.unity3d.player.UnityPlayer;
private void validateTransaction(URL url) throws JSONException {
        String transaction = getTransactionData(url);
        if (transaction == null) return;
        JSONObject json = new JSONObject(transaction);
        JSONObject transactionDetails = json.getJSONArray("items").getJSONObject(0);
        Uri ospUri = Uri.parse(OSPActivity.url);

        String methodToCall = isValid(transactionDetails, ospUri) ? "OnPurchaseSuccess" : "OnPurchaseUnsuccessful";
        Log.d(TAG, methodToCall);
        /*UnityPlayer.UnitySendMessage(
            "PurchaseManager",
            methodToCall,
            ospUri.getQueryParameter("product")
        )*/
}

private boolean isValid(JSONObject transactionDetails, Uri ospUri) throws JSONException {
        JSONObject priceDetails = transactionDetails.getJSONObject("price");
        String product = ospUri.getQueryParameter("product");
        String domain = ospUri.getQueryParameter("domain");
        String reference = ospUri.getQueryParameter("reference");
        if (reference == null) reference = "null";
        double value = Double.parseDouble(ospUri.getQueryParameter("value"));

        return product.equals(transactionDetails.getString("product"))
                && domain.equals(transactionDetails.getString("domain"))
                && reference.equals(transactionDetails.getString("reference"))
                && value == priceDetails.getDouble("usd");
}

private String getTransactionData(URL url) {
        HttpURLConnection urlConnection = null;
        try {
            urlConnection = (HttpURLConnection) url.openConnection();
            InputStream in = new BufferedInputStream(urlConnection.getInputStream());
            // Function that returns a string from an InputStream
            return readStream(in);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        } finally {
            urlConnection.disconnect();
        }
}

Also, make sure the app has internet permission in Android Manifest:

<uses-permission android:name="android.permission.INTERNET" />

7. Create a Purchase Manager

As explained with comments, to inform Unity scripts of the result it is used the UnitySendMessage, that in this example we use to call a method from PurchaseManager with the product as an argument, to make this work add the game object PurchaseManager and attach to it a script with the methods called from the activity. This script is where you can implement the logic for what to do with successful and unsuccessful transactions.