Skip to content
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
53e264a
Add RevenueCatUI package
facumenzella Sep 10, 2025
f9683a5
revert code
facumenzella Sep 10, 2025
0a407d2
Add minimum
facumenzella Sep 10, 2025
151ef83
Moved files to RevenueCat/Android
facumenzella Sep 15, 2025
45ad7d0
Moved files from Runtime to Scripts
facumenzella Sep 15, 2025
c4ac9de
Use SimpleJSON
facumenzella Sep 15, 2025
e20e4ce
delete extra .meta
facumenzella Sep 15, 2025
cdd8ea2
Updated bool IsSupported(); doc
facumenzella Sep 15, 2025
be566dd
Added pure code options, removed GameObjecft
facumenzella Sep 15, 2025
c550a2e
Split callback
facumenzella Sep 15, 2025
d1d075e
added debug logs
facumenzella Sep 15, 2025
1a0e59c
Remove StubPaywallPresenter.cs
facumenzella Sep 15, 2025
8b7784b
Delete RevenueCatUICallbackHandler.cs
facumenzella Sep 15, 2025
69096d4
Add missing pieces
facumenzella Sep 15, 2025
c191052
Merge branch 'main' into feat/add-revenuecatui
facumenzella Sep 15, 2025
1ee5dcc
ready
facumenzella Sep 15, 2025
2cbfef6
remove reference to ui package
facumenzella Sep 15, 2025
160ed40
rename to com.revenuecat.unity.ui
facumenzella Sep 16, 2025
e03df34
Merge branch 'main' into feat/add-revenuecatui
facumenzella Sep 16, 2025
c3bd377
Update Subtester/Assets/Scenes/Main.unity
facumenzella Sep 17, 2025
0ad7ba1
Remove extra Stub.meta
facumenzella Sep 17, 2025
a8591ee
Remove README
facumenzella Sep 17, 2025
ffca566
Rename .asmdef files
facumenzella Sep 17, 2025
5417e16
Remove customer center code
facumenzella Sep 17, 2025
9ec1edf
Merge branch 'feat/add-revenuecatui' of github.com:RevenueCat/purchas…
facumenzella Sep 17, 2025
6a43d86
remove _isSupportedCache
facumenzella Sep 17, 2025
0a829d0
Update Subtester/Packages/manifest.json
facumenzella Sep 17, 2025
18b9ba1
renamed editor asmdef and deleted extra files
facumenzella Sep 17, 2025
21ac4df
Merge branch 'feat/add-revenuecatui' of github.com:RevenueCat/purchas…
facumenzella Sep 17, 2025
17aed4a
revert Subtester
facumenzella Sep 17, 2025
d3cca45
Update Subtester/Assets/Plugins/Android/mainTemplate.gradle
facumenzella Sep 17, 2025
f51be9e
Update Subtester/ProjectSettings/AndroidResolverDependencies.xml
facumenzella Sep 17, 2025
1e1ee01
Make properties internal
facumenzella Sep 17, 2025
0143afe
Merge branch 'feat/add-revenuecatui' of github.com:RevenueCat/purchas…
facumenzella Sep 17, 2025
2693a44
minimal paywalls presentation
vegaro Aug 28, 2025
97dec08
clean up
vegaro Sep 17, 2025
980a0e5
Merge branch 'main' into paywalls-android-poc
vegaro Sep 17, 2025
b9bb1e0
clean up
vegaro Sep 17, 2025
b048332
clean up
vegaro Sep 22, 2025
cfbc8d9
move into androidlib
vegaro Sep 22, 2025
1c4c865
add local dependency
vegaro Sep 22, 2025
f072bde
updated mainTemplate.gradle
vegaro Sep 22, 2025
2457709
update PurchasesListener
vegaro Sep 22, 2025
44bc909
gitignore
vegaro Sep 22, 2025
eb27acf
add log to AndroidPaywallPresenter
vegaro Sep 22, 2025
5da75f8
add displayCloseButton
vegaro Sep 22, 2025
56258da
fix compilation of Subtester when exporting package
vegaro Sep 22, 2025
be4ef13
use componentActivity
vegaro Sep 22, 2025
649cff4
presentPaywallIfNeeded
vegaro Sep 22, 2025
e55f375
add close button to log
vegaro Sep 22, 2025
26961f3
add PresentPaywallIfNeeded to PurchasesListener
vegaro Sep 22, 2025
07afd3d
update AndroidResolverDependencies
vegaro Sep 22, 2025
dde2af5
implements PaywallResultHandler
vegaro Sep 26, 2025
96d73f6
rename to PaywallTrampolineActivity and made transparent
vegaro Sep 26, 2025
cf1a839
remove unneeded comment
vegaro Sep 26, 2025
09651b4
TODOs on logs
vegaro Sep 26, 2025
8065057
remove UnityBridge
vegaro Sep 26, 2025
82643ac
Paywalls iOS (#675)
facumenzella Sep 26, 2025
52a8260
Merge branch 'main' into paywalls-android-poc
vegaro Sep 29, 2025
40f1404
small PR comments
vegaro Sep 29, 2025
830e978
Merge branch 'paywalls-android-poc' of github.com:RevenueCat/purchase…
vegaro Sep 29, 2025
588ea5d
Another TODO
vegaro Sep 29, 2025
79dfe20
static functions in trampoline activity
vegaro Sep 29, 2025
bb5af22
create PaywallUnityOptions
vegaro Sep 29, 2025
34b2f4a
add PaywallUnityOptions to signature
vegaro Sep 29, 2025
bba5ea9
remove gameObjectName from parameters
vegaro Sep 29, 2025
903aad4
fix versions
vegaro Sep 30, 2025
bed291d
Extract paywall result strings to constants in PaywallTrampolineActiv…
vegaro Oct 1, 2025
3ec3b42
fix indentation
vegaro Oct 1, 2025
cba2097
change default display close button
vegaro Oct 1, 2025
aabecf5
AndroidApplication.currentActivity
vegaro Oct 1, 2025
bcc83a6
better handling of exceptions on AndroidJavaClass
vegaro Oct 1, 2025
b36f845
remove MonoBehaviour
vegaro Oct 1, 2025
e032186
remove ?? "default"
vegaro Oct 1, 2025
ed13583
Merge branch 'main' into paywalls-android-poc
vegaro Oct 1, 2025
c4fa9e7
fix PurchasesListener
vegaro Oct 2, 2025
1601b1b
update version
vegaro Oct 2, 2025
08005f2
Merge branch 'main' into paywalls-android-poc
vegaro Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ fastlane/.env
# CircleCI folders
vendor/
.bundle/
RevenueCatUI/Plugins/Android/RevenueCatUI.androidlib/build
1 change: 0 additions & 1 deletion RevenueCat/Scripts/Purchases.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1499,7 +1499,6 @@ private void _handleLog(string logDetailsJson)
LogHandler(logLevel, messageInResponse);
}


// ReSharper disable once UnusedMember.Local
private void _restorePurchases(string customerInfoJson)
{
Expand Down
19 changes: 0 additions & 19 deletions RevenueCatUI/Editor/RevenueCatUI.Editor.asmdef

This file was deleted.

61 changes: 61 additions & 0 deletions RevenueCatUI/Plugins/Android/RevenueCatUI.androidlib.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<activity
android:name=".PaywallProxyActivity"
android:exported="false"
android:theme="@android:style/Theme.NoTitleBar"
tools:node="merge" />
Comment on lines +6 to +10
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These kinds of activities are often called "trampoline" activities. Maybe we can use that name for ours as well, PaywallTrampolineActivity?

This blog post by Marcello Galhardo has some details on the pattern, one of which is the tip to use the @android:style/Theme.NoDisplay theme. We discussed that we should ensure no UI glitches and transition animations happen. Maybe that theme is all we need?

Copy link
Copy Markdown
Member Author

@vegaro vegaro Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice blogpost! It looks like "trampoline" activities are finished as soon as they are created:

class MyTrampolineActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // Start your next activity.
    finish()
  }
}

But in our case, we want the Activity to send the message through the Unity bridge. If we finish the Activity we won't be able to send that. We need the activity to stay alive to receive PaywallResult from the launcher basically.

Also from the docs of Theme.NoDisplay

Default theme for activities that don't actually display a UI; that is, they finish themselves before being resumed.

I could create a static callback manager that implements PaywallResultHandler for the results of the PaywallActivity, but I think there's risk of leaking the trampoline Activity? What do you think? @JayShortway

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the activity to stay alive

Hmm good point, but I don't think we can assume that actually. Depending on the device and memory constraints, activities on the backstack might be killed. We can test this by enabling Don't keep activities in the developer options.

Maybe we need a callback manager after all? Why do you think there's a risk of leaking the trampoline Activity (just curious)?

Copy link
Copy Markdown
Member Author

@vegaro vegaro Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm now I am actually not sure. My thinking was:

  1. launcher is created in PaywallTrampolineActivity. The PaywallResultHandler is a static callback manager
  2. PaywallTrampolineActivity is finished before onResume.
  3. Does the launcher prevent PaywallTrampolineActivity from being garbage collected because it holds a reference to the PaywallResultHandler which is a static class that's not destroyed?

Like can the PaywallResultHandler live longer than the launcher?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like can the PaywallResultHandler live longer than the launcher?

Yes, if launcher holds a reference to PaywallResultHandler (and not the other way around), PaywallResultHandler can live longer.

I wonder how it works if we enable Don't keep activities, and have PaywallTrampolineActivity launch the paywall. My assumption is that the framework handles delivering the paywall result back to PaywallTrampolineActivity, even if that was killed in the meantime. We can also check if finishing the PaywallTrampolineActivity after launching the paywall has any effect on this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will double check. That will definitely make this a trampoline, which is beneficial

Copy link
Copy Markdown
Member Author

@vegaro vegaro Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw @JayShortway I did try this (without Don't keep activities):

launcher = new PaywallActivityLauncher(this, new PaywallResultHandler() {
            @Override
            public void onActivityResult(PaywallResult o) {
                Log.e("Testing", "PaywallTrampolineActivity onActivityResult: " + o);
            }
        });

and finishing the activity right at the end of onCreate (after launching the paywall), and the PaywallResultHandler doesn't get called, so it looks like the result handler can't survive the Activity

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I did test "Don't keep activities" and as soon as the paywall is seen, the app restarts. It looks like UnityPlayerActivity gets killed and the app is restarted

</application>

<!-- No special permissions required -->

</manifest>


Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
apply plugin: 'com.android.library'

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.revenuecat.purchases:purchases-hybrid-common:17.7.0'
implementation 'com.revenuecat.purchases:purchases-hybrid-common-ui:17.7.0'
implementation 'androidx.activity:activity:1.8.2'
}

android {
namespace 'com.revenuecat.purchasesunity.ui'

compileSdk getProperty("unity.compileSdkVersion") as int
buildToolsVersion = getProperty("unity.buildToolsVersion")

compileOptions {
sourceCompatibility JavaVersion.valueOf(getProperty("unity.javaCompatabilityVersion"))
targetCompatibility JavaVersion.valueOf(getProperty("unity.javaCompatabilityVersion"))
}

sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
java.srcDirs = ['src/main/java']
res.srcDirs = ['src/main/res']
assets.srcDirs = ['src/main/assets']
jniLibs.srcDirs = ['src/main/jniLibs']
}
}

def unityLib = project(':unityLibrary').extensions.getByName('android')

defaultConfig {
consumerProguardFiles "consumer-proguard.pro"
minSdkVersion unityLib.defaultConfig.minSdkVersion.mApiLevel
targetSdkVersion unityLib.defaultConfig.targetSdkVersion.mApiLevel
}

lintOptions {
abortOnError false
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules. pro'
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-keep class com.unity3d.player.** { *; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.revenuecat.purchasesunity.ui;

import android.content.Intent;
import android.os.Bundle;
import android.util.Log;

import androidx.annotation.Nullable;
import androidx.activity.ComponentActivity;

import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallActivityLauncher;
import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult;
import com.revenuecat.purchases.PresentedOfferingContext;

public class PaywallProxyActivity extends ComponentActivity {
public static final String EXTRA_GAME_OBJECT = "rc_proxy_game_object";
public static final String EXTRA_METHOD = "rc_proxy_method";
public static final String EXTRA_OFFERING_ID = "rc_offering_id";
public static final String EXTRA_SHOULD_DISPLAY_DISMISS_BUTTON = "rc_should_display_dismiss_button";
public static final String EXTRA_REQUIRED_ENTITLEMENT_IDENTIFIER = "rc_required_entitlement_identifier";

private static final String TAG = "PurchasesUnity";

private String gameObject;
private String method;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

final Intent source = getIntent();
gameObject = source.getStringExtra(EXTRA_GAME_OBJECT);
method = source.getStringExtra(EXTRA_METHOD);
String offeringId = source.getStringExtra(EXTRA_OFFERING_ID);
boolean shouldDisplayDismissButton = source.getBooleanExtra(EXTRA_SHOULD_DISPLAY_DISMISS_BUTTON, false);
String requiredEntitlementIdentifier = source.getStringExtra(EXTRA_REQUIRED_ENTITLEMENT_IDENTIFIER);

if (gameObject == null || method == null) {
Log.w(TAG, "Missing gameObject/method extras; finishing.");
finish();
return;
}

PaywallActivityLauncher launcher = new PaywallActivityLauncher(
this,
result -> {
try {
if (result != null) {
sendPaywallResult(result);
}
} finally {
finish();
}
}
);

if (requiredEntitlementIdentifier != null) {
Log.d(TAG, "Using launchIfNeeded for entitlement '" + requiredEntitlementIdentifier + "'");
launchPaywallIfNeeded(launcher, requiredEntitlementIdentifier, offeringId, shouldDisplayDismissButton);
} else {
Log.d(TAG, "No entitlement check required, presenting paywall directly");
launchPaywall(launcher, offeringId, shouldDisplayDismissButton);
}
}

private void launchPaywallIfNeeded(PaywallActivityLauncher launcher, String requiredEntitlementIdentifier, String offeringId, boolean shouldDisplayDismissButton) {
Log.d(TAG, "Launching paywall if needed with PaywallActivityLauncher");
Log.d(TAG, "Options - entitlement: " + requiredEntitlementIdentifier + ", offering: " + offeringId + ", dismissButton: " + shouldDisplayDismissButton);

if (offeringId != null) {
Log.d(TAG, "Using launchIfNeeded with offering ID");
launcher.launchIfNeeded(
requiredEntitlementIdentifier,
offeringId,
new PresentedOfferingContext(offeringId), // TODO: pass PresentedOfferingContext data
null, // fontProvider
shouldDisplayDismissButton,
false, // edgeToEdge
paywallDisplayResult -> {
if (!paywallDisplayResult) {
Log.d(TAG, "PaywallDisplayCallback: paywall not needed");
sendResult("NOT_PRESENTED");
finish();
}
// If paywallDisplayResult is true, the paywall will be shown and result will come through normal callback
}
);
} else {
Log.w(TAG, "launchIfNeeded requires an offering ID, falling back to regular launch");
launchPaywall(launcher, offeringId, shouldDisplayDismissButton);
}
}

private void launchPaywall(PaywallActivityLauncher launcher, String offeringId, boolean shouldDisplayDismissButton) {
Log.d(TAG, "Launching paywall with PaywallActivityLauncher");
Log.d(TAG, "Options - offering: " + offeringId + ", dismissButton: " + shouldDisplayDismissButton);

if (offeringId != null) {
Log.d(TAG, "Launching paywall with offering ID");
launcher.launch(offeringId,
new PresentedOfferingContext(offeringId), // TODO: pass PresentedOfferingContext data
null, // fontProvider
shouldDisplayDismissButton);
} else {
Log.d(TAG, "Launching paywall with standard method");
launcher.launch(
null, // offering (Offering object, not String)
null, // fontProvider
shouldDisplayDismissButton
);
}
}

private void sendResult(String resultName) {
Log.d(TAG, "Sending result: " + resultName);
runOnUiThread(() -> UnityBridge.sendMessage(gameObject, method, resultName));
}

private void sendPaywallResult(PaywallResult result) {
final String resultName;
if (result instanceof PaywallResult.Purchased) {
resultName = "purchased";
} else if (result instanceof PaywallResult.Restored) {
resultName = "restored";
} else if (result instanceof PaywallResult.Cancelled) {
resultName = "cancelled";
} else if (result instanceof PaywallResult.Error) {
resultName = "error";
} else {
resultName = "cancelled";
}

sendResult(resultName);
}
}
Loading