diff --git a/.github/actions/publish_maven_central/action.yml b/.github/actions/publish_maven_central/action.yml
new file mode 100644
index 00000000..515518e1
--- /dev/null
+++ b/.github/actions/publish_maven_central/action.yml
@@ -0,0 +1,248 @@
+name: 'Publish to Maven Central'
+description: 'Publishes all modules using the Maven Central Portal Plugin'
+inputs:
+ sonatype_user:
+ description: 'Sonatype user'
+ required: true
+ default: ''
+ sonatype_token:
+ description: 'Sonatype user token'
+ required: true
+ default: ''
+ signing_key_id:
+ description: 'Signing key id'
+ required: true
+ default: ''
+ signing_key_password:
+ description: 'Signing key password'
+ required: true
+ default: ''
+ signing_key_file:
+ description: 'Signing key file'
+ required: true
+ default: ''
+ auto_publish:
+ description: 'Whether to publish automatically'
+ required: false
+ default: 'true'
+ use_snapshot:
+ description: 'Whether to publish as a SNAPSHOT version'
+ required: false
+ default: 'false'
+runs:
+ using: "composite"
+ steps:
+ - name: Set up Maven
+ uses: stCarolas/setup-maven@v4.5
+ with:
+ maven-version: 3.9.5
+
+ - name: Create Maven settings directory
+ shell: bash
+ run: |
+ mkdir -p ${{ github.workspace }}/.mvn
+
+ - name: Create Maven settings file
+ shell: bash
+ run: |
+ cat > ${{ github.workspace }}/.mvn/maven-settings.xml << 'EOF'
+
+
+
+
+ central
+ ${env.SONATYPE_NEXUS_USERNAME}
+ ${env.SONATYPE_NEXUS_PASSWORD}
+
+
+
+
+ gpg
+
+ gpg
+ ${env.SIGNING_KEY_ID}
+ ${env.SIGNING_KEY_PASSWORD}
+
+
+
+
+ gpg
+
+
+ EOF
+
+ - name: Prepare and publish
+ shell: bash
+ env:
+ SONATYPE_NEXUS_USERNAME: ${{ inputs.sonatype_user }}
+ SONATYPE_NEXUS_PASSWORD: ${{ inputs.sonatype_token }}
+ SIGNING_KEY_ID: ${{ inputs.signing_key_id }}
+ SIGNING_KEY_PASSWORD: ${{ inputs.signing_key_password }}
+ SIGNING_KEY_FILE: ${{ inputs.signing_key_file }}
+ USE_SNAPSHOT: ${{ inputs.use_snapshot }}
+ run: |
+ echo "Using Gradle publishing with Maven Central Portal"
+
+ # Verify we have the library module
+ if [ ! -d "${{ github.workspace }}/library" ]; then
+ echo "Error: library directory not found"
+ exit 1
+ fi
+
+ # Get version from build.gradle
+ VERSION=$(grep -o '"sdkVersionName"\s*:\s*"[^"]*"' ${{ github.workspace }}/build.gradle | grep -o '"[^"]*"$' | tr -d '"')
+
+ # Check if this is a snapshot release
+ if [ "$USE_SNAPSHOT" = "true" ]; then
+ # Add -SNAPSHOT suffix if it's not already there
+ if [[ "$VERSION" != *-SNAPSHOT ]]; then
+ VERSION="${VERSION}-SNAPSHOT"
+ fi
+ echo "Publishing SNAPSHOT version: $VERSION"
+ else
+ # Remove -SNAPSHOT suffix if it exists for release versions
+ if [[ "$VERSION" == *-SNAPSHOT ]]; then
+ VERSION="${VERSION%-SNAPSHOT}"
+ fi
+ echo "Publishing release version: $VERSION"
+ fi
+
+ # Get proper artifactId from library's build.gradle
+ ARTIFACT_ID=$(grep -o 'project.ext.name = "[^"]*"' ${{ github.workspace }}/library/build.gradle | grep -o '"[^"]*"$' | tr -d '"')
+ echo "Using artifactId: $ARTIFACT_ID"
+ echo "Publishing version: $VERSION"
+
+ # Check if this version might already exist
+ if [ "$USE_SNAPSHOT" != "true" ]; then
+ echo "⚠️ WARNING: Publishing release version $VERSION"
+ echo " If this version already exists on Maven Central, the publish will fail."
+ echo " Consider using use_snapshot: true for testing or increment the version number."
+ fi
+
+ # Import GPG key
+ echo "Importing GPG key for signing"
+ gpg --batch --import ${{ inputs.signing_key_file }}
+
+ # Apply NMCP plugin to root build.gradle for aggregation
+ echo "Configuring root build.gradle with NMCP plugin for Maven Central Portal"
+
+ # Backup the root build.gradle
+ cp ${{ github.workspace }}/build.gradle ${{ github.workspace }}/build.gradle.backup
+
+ # Create script file for awk
+ cat > ${{ github.workspace }}/awk-script.txt << 'EOF'
+ /^plugins \{/ {
+ print $0
+ print " id \"com.gradleup.nmcp.aggregation\" version \"1.0.2\""
+ next
+ }
+ { print }
+ EOF
+
+ # Apply the awk script
+ awk -f ${{ github.workspace }}/awk-script.txt ${{ github.workspace }}/build.gradle.backup > ${{ github.workspace }}/build.gradle.tmp
+ mv ${{ github.workspace }}/build.gradle.tmp ${{ github.workspace }}/build.gradle
+
+ # Create a file with NMCP configuration
+ cat > ${{ github.workspace }}/nmcp-config.gradle << EOF
+ nmcpAggregation {
+ centralPortal {
+ username = System.getenv("SONATYPE_NEXUS_USERNAME")
+ password = System.getenv("SONATYPE_NEXUS_PASSWORD")
+ publishingType = "AUTOMATIC"
+ publicationName = "PayPal Messages Android"
+ }
+
+ // Publish the library project
+ publishAllProjectsProbablyBreakingProjectIsolation()
+ }
+
+ // Override the version for the publication and ensure POM metadata is included
+ afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ version = "${VERSION}"
+ pom {
+ name = "PayPal Messages"
+ description = "The PayPal Android SDK Messages Module: Promote offers to your customers such as Pay Later and PayPal Credit."
+ url = "https://github.com/paypal/paypal-messages-android"
+ licenses {
+ license {
+ name = "The Apache License, Version 2.0"
+ url = "http://www.apache.org/licenses/LICENSE-2.0"
+ }
+ }
+ developers {
+ developer {
+ id = "paypal-messages-android"
+ name = "PayPalMessages Android"
+ email = "sdks-messages@paypal.com"
+ }
+ }
+ scm {
+ connection = "scm:git:git://github.com/paypal/paypal-messages-android.git"
+ developerConnection = "scm:git:ssh://github.com:paypal/paypal-messages-android.git"
+ url = "https://github.com/paypal/paypal-messages-android"
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Configure signing for all tasks
+ signing {
+ required { true }
+ sign publishing.publications
+ }
+ EOF
+
+ # Add NMCP configuration to the end of root build.gradle
+ cat ${{ github.workspace }}/nmcp-config.gradle >> ${{ github.workspace }}/build.gradle
+
+ # Clean before building to ensure fresh state
+ echo "Cleaning project..."
+ ./gradlew clean
+
+ # Use NMCP aggregation to publish to Maven Central Portal
+ echo "Building and publishing with NMCP aggregation..."
+ ./gradlew --stacktrace \
+ publishAggregationToCentralPortal \
+ -Psigning.keyId="${SIGNING_KEY_ID}" \
+ -Psigning.password="${SIGNING_KEY_PASSWORD}" \
+ -Psigning.secretKeyRingFile="${SIGNING_KEY_FILE}"
+
+ # Restore the original configurations
+ if [ -f "${{ github.workspace }}/gradle/gradle-publish.gradle.backup" ]; then
+ mv ${{ github.workspace }}/gradle/gradle-publish.gradle.backup ${{ github.workspace }}/gradle/gradle-publish.gradle
+ fi
+ if [ -f "${{ github.workspace }}/build.gradle.backup" ]; then
+ mv ${{ github.workspace }}/build.gradle.backup ${{ github.workspace }}/build.gradle
+ fi
+
+ # Print deployment information and next steps
+ echo "======================================================================"
+ echo "Deployment to Maven Central completed successfully."
+ echo "Your artifacts are now being published to Maven Central as:"
+ echo "com.paypal.messages:${ARTIFACT_ID}:${VERSION}"
+ echo ""
+ echo "The following artifacts were published:"
+ echo "- ${ARTIFACT_ID}-${VERSION}.aar (Android AAR)"
+ echo "- ${ARTIFACT_ID}-${VERSION}-sources.jar (Sources JAR)"
+ echo "- ${ARTIFACT_ID}-${VERSION}.module (Gradle Module Metadata)"
+ echo "- ${ARTIFACT_ID}-${VERSION}.pom (POM file)"
+ echo "- All signature (.asc) and checksum files"
+ echo ""
+ echo "Users can include this dependency with:"
+ echo "implementation(\"com.paypal.messages:${ARTIFACT_ID}:${VERSION}\")"
+ echo ""
+ echo "NOTE: After successful publishing, it may take several hours for"
+ echo "the artifacts to appear on Maven Central (https://repo.maven.apache.org/maven2)"
+ echo "and search indexes like mvnrepository.com or search.maven.org."
+ echo ""
+ echo "You can check the status of your deployment at:"
+ echo "https://central.sonatype.com/publishing/deployments"
+ echo "======================================================================"
\ No newline at end of file
diff --git a/.github/workflows/release-snapshots.yml b/.github/workflows/release-snapshots.yml
index ee6cc631..08c6fa79 100644
--- a/.github/workflows/release-snapshots.yml
+++ b/.github/workflows/release-snapshots.yml
@@ -60,9 +60,9 @@ jobs:
SIGNING_KEY_FILE: ${{ env.SIGNING_KEY_FILE_PATH }}
run: npx semantic-release@21
- # Central Portal publishing method
- - name: Publish to Central Portal
- uses: ./.github/actions/publish_central_portal
+ # Maven Central publishing method using official plugin
+ - name: Publish to Maven Central
+ uses: ./.github/actions/publish_maven_central
with:
# Pass credentials directly as input parameters
sonatype_user: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
@@ -70,4 +70,4 @@ jobs:
signing_key_id: ${{ secrets.SIGNING_KEY_ID }}
signing_key_password: ${{ secrets.SIGNING_KEY_PASSWORD }}
signing_key_file: ${{ env.SIGNING_KEY_FILE_PATH }}
- auto_publish: 'true'
+ auto_publish: 'false'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0b496c9b..fc801a80 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -68,9 +68,9 @@ jobs:
target_branch: develop
github_token: ${{ secrets.GITHUB_TOKEN }}
- # Central Portal publishing method
- - name: Publish to Central Portal
- uses: ./.github/actions/publish_central_portal
+ # Maven Central publishing method using official plugin
+ - name: Publish to Maven Central
+ uses: ./.github/actions/publish_maven_central
with:
# Pass credentials directly as input parameters
sonatype_user: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
@@ -78,4 +78,4 @@ jobs:
signing_key_id: ${{ secrets.SIGNING_KEY_ID }}
signing_key_password: ${{ secrets.SIGNING_KEY_PASSWORD }}
signing_key_file: ${{ env.SIGNING_KEY_FILE_PATH }}
- auto_publish: 'true'
+ auto_publish: 'false'
diff --git a/.gitignore b/.gitignore
index ba827ca0..44d5a1db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@ node_modules/
# Recommended Ignores by JetBrains
# https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# .idea/ files are handled in .idea/.gitignore
+connectedCheck.log
+.idea/deploymentTargetSelector.xml
+.idea/migrations.xml
+.idea/other.xml
diff --git a/.mvn/maven-settings.xml b/.mvn/maven-settings.xml
new file mode 100644
index 00000000..3a8c4f7e
--- /dev/null
+++ b/.mvn/maven-settings.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ central
+
+ ${env.SONATYPE_NEXUS_USERNAME}
+ ${env.SONATYPE_NEXUS_PASSWORD}
+
+
+
\ No newline at end of file
diff --git a/PROGUARD.md b/PROGUARD.md
new file mode 100644
index 00000000..27332d53
--- /dev/null
+++ b/PROGUARD.md
@@ -0,0 +1,124 @@
+# ProGuard Rules for PayPal Messages Android SDK
+
+This document outlines the ProGuard rules required for proper operation of the PayPal Messages Android SDK and its dependencies.
+
+## Core Library Dependencies
+
+The PayPal Messages Android SDK relies on several key dependencies that require specific ProGuard rules:
+
+1. **OkHttp3** - Used for network communication
+2. **Gson** - Used for JSON serialization/deserialization
+3. **Kotlin Coroutines** - Used for asynchronous operations
+
+## Required ProGuard Rules
+
+### PayPal Messages SDK Rules
+
+```proguard
+# Keep all PayPal Messages SDK classes
+-keep class com.paypal.messages.** { *; }
+-keepnames class com.paypal.messages.** { *; }
+
+# Keep classes that might be accessed via reflection
+-keepclassmembers class * {
+ @com.paypal.messages.** *;
+}
+
+# Keep references to the library from client apps
+-dontwarn com.paypal.messages.**
+```
+
+### Kotlin Rules
+
+```proguard
+# Keep Kotlin reflection support
+-keep class kotlin.reflect.** { *; }
+-keep class kotlin.Metadata { *; }
+
+# Keep attributes needed for Kotlin reflection
+-keepattributes *Annotation*, InnerClasses
+-keepattributes SourceFile, LineNumberTable
+-keepattributes Signature, Exceptions
+
+# Keep Kotlin Coroutines
+-keep class kotlinx.coroutines.** { *; }
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+-keepclassmembers class kotlin.coroutines.** { *; }
+-keep class kotlin.coroutines.** { *; }
+```
+
+### OkHttp Rules
+
+```proguard
+# Keep OkHttp classes
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+-dontwarn okhttp3.**
+-dontwarn okio.**
+
+# OkHttp Platform used when running on Java 8 or below
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.conscrypt.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.openjsse.**
+```
+
+### Gson Rules
+
+```proguard
+# Keep Gson classes
+-keep class com.google.gson.** { *; }
+-keep class * implements com.google.gson.TypeAdapterFactory
+-keep class * implements com.google.gson.JsonSerializer
+-keep class * implements com.google.gson.JsonDeserializer
+-keepattributes Signature
+```
+
+### General Rules
+
+```proguard
+# Keep WebView JavaScript interface
+-keepclassmembers class * {
+ @android.webkit.JavascriptInterface ;
+}
+
+# Keep native methods
+-keepclasseswithmembernames class * {
+ native ;
+}
+
+# Keep enum classes
+-keepclassmembers enum * {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+# Keep model classes that might be serialized/deserialized
+-keep class * implements android.os.Parcelable { *; }
+```
+
+## Verifying ProGuard Rules
+
+To verify that the ProGuard rules are working correctly:
+
+1. Build your app with minification enabled
+2. Test all functionality related to PayPal Messages
+3. Check for any `ClassNotFoundException` or `NoSuchMethodError` exceptions
+
+The demo app includes a test build variant with minification enabled (`debugWithMinifyEnabled`) and a test activity that exercises all the key libraries to verify the ProGuard rules.
+
+## Troubleshooting
+
+If you encounter issues with ProGuard:
+
+1. Check logcat for specific class or method not found errors
+2. Add specific keep rules for the missing classes
+3. Use the `-verbose` option with ProGuard to get more information
+4. Consider using `-dontobfuscate` during debugging to isolate minification issues
+
+## Additional Resources
+
+- [Android Developer documentation on shrinking code](https://developer.android.com/studio/build/shrink-code)
+- [ProGuard manual](https://www.guardsquare.com/manual/configuration/usage)
\ No newline at end of file
diff --git a/PUBLISHING.md b/PUBLISHING.md
index e83d09eb..69ac5525 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -91,11 +91,25 @@ Both methods use the same secrets for authentication:
- `SONATYPE_NEXUS_USERNAME` - Your Sonatype username
- `SONATYPE_NEXUS_PASSWORD` - Your Sonatype user token
+## Manual Publishing Approval
+
+When using the `USER_MANAGED` publishing mode (default for releases), you need to manually approve the deployment after validation:
+
+1. After the GitHub Action completes the upload, it will show a success message with a link to the Sonatype Central Portal
+2. Go to the Sonatype Central Portal deployments page: https://central.sonatype.com/publishing/deployments
+3. Find your deployment in the list (it will show as "PUBLISHING" status)
+4. Click on the deployment to view details
+5. After reviewing the artifacts, click the "Publish" button to finalize the publishing process
+6. Your artifacts will then be published to Maven Central (this may take a few hours to appear in all indices)
+
+
+
## Repository URLs
### Central Portal API URLs
- Upload Endpoint: https://central.sonatype.com/api/v1/publisher/upload
- Status Endpoint: https://central.sonatype.com/api/v1/publisher/status
+- Deployments Page: https://central.sonatype.com/publishing/deployments
## Authentication
@@ -123,6 +137,10 @@ If you encounter issues with the Central Portal API:
4. Verify that the package metadata (groupId, artifactId, version) is correct
5. Check the GitHub Actions logs for any credential or authentication issues
+### Common Issues
+- **"Component already exists" error**: This means you're trying to publish a version that already exists. Maven Central doesn't allow overwriting published artifacts. Use a new version number.
+- **Missing manual approval**: For releases, you must manually approve the deployment by clicking the "Publish" button on the Central Portal deployments page: https://central.sonatype.com/publishing/deployments
+
### Testing Locally
When testing publishing locally, you may see warning messages about missing credentials. The build system will use dummy values for testing, which won't actually publish anything. This is expected behavior and helps with local testing without requiring real credentials.
diff --git a/build.gradle b/build.gradle
index c4eccc1c..f0e2d745 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,7 +4,7 @@ import org.jmailen.gradle.kotlinter.tasks.FormatTask
buildscript {
ext.modules = [
- "sdkVersionName" : "1.1.0",
+ "sdkVersionName" : "1.1.2",
"androidMinSdkVersion": 23,
"androidTargetVersion": 34
]
@@ -21,6 +21,7 @@ plugins {
id 'org.jetbrains.kotlin.android' version '1.8.22' apply false
id 'org.jmailen.kotlinter' version '3.16.0'
id 'signing'
+ id 'maven-publish'
}
tasks.register('ktLint', LintTask) {
@@ -41,65 +42,186 @@ version modules.sdkVersionName
// Central Portal API integration
+import org.apache.http.client.methods.HttpGet
import org.apache.http.client.methods.HttpPost
import org.apache.http.entity.ContentType
import org.apache.http.entity.mime.MultipartEntityBuilder
import org.apache.http.impl.client.HttpClients
import org.apache.http.util.EntityUtils
import com.fasterxml.jackson.databind.ObjectMapper
+import java.net.URLEncoder
+import org.apache.tools.ant.taskdefs.condition.Os
tasks.register('publishToCentralPortal') {
group = 'publishing'
- description = 'Publishes artifacts to Sonatype Central Portal'
+ description = 'Publishes artifacts to Sonatype Central Portal using the Maven plugin'
- dependsOn 'assemble'
- // We don't depend on specific signing tasks here
- // as they'll be handled by the publishing task
+ // Check if signing is configured
+ def isSigningConfigured = System.getenv('SIGNING_KEY_ID') &&
+ System.getenv('SIGNING_KEY_PASSWORD') &&
+ System.getenv('SIGNING_KEY_FILE') &&
+ !System.getenv('SIGNING_KEY_ID').isEmpty() &&
+ !System.getenv('SIGNING_KEY_PASSWORD').isEmpty() &&
+ !System.getenv('SIGNING_KEY_FILE').isEmpty()
+
+ if (isSigningConfigured) {
+ dependsOn ':library:assemble', ':library:signArchives'
+ } else {
+ dependsOn ':library:assemble'
+ }
doLast {
- // Look for token in multiple places for flexibility
+ logger.lifecycle("====================================================================")
+ logger.lifecycle("Publishing to Maven Central using Central Portal Maven Plugin")
+ logger.lifecycle("====================================================================")
+
+ // Make sure we have credentials
+ def username = System.getenv('SONATYPE_NEXUS_USERNAME') ?:
+ findProperty('sonatypeUsername') ?:
+ project.hasProperty('sonatypeUsername') ? project.getProperty('sonatypeUsername') : null
+
def token = System.getenv('SONATYPE_NEXUS_PASSWORD') ?:
- findProperty('sonatypePassword') ?:
- project.hasProperty('sonatypePassword') ? project.getProperty('sonatypePassword') : null
+ findProperty('sonatypePassword') ?:
+ project.hasProperty('sonatypePassword') ? project.getProperty('sonatypePassword') : null
- if (!token) {
- logger.warn("SONATYPE_NEXUS_PASSWORD environment variable or sonatypePassword property not set")
- logger.warn("Using 'demo-token' for testing only. For production, set SONATYPE_NEXUS_PASSWORD")
- token = "demo-token" // Only for testing - will not work in production!
+ if (!username || !token) {
+ throw new GradleException("SONATYPE_NEXUS_USERNAME and SONATYPE_NEXUS_PASSWORD environment variables or properties are required")
}
- // The API endpoint
- def apiUrl = "https://central.sonatype.com/api/v1/publisher/upload"
- def publishingType = project.hasProperty('autoPublish') ? "AUTOMATIC" : "USER_MANAGED"
+ // Ensure we have Maven available
+ def mvnExecutable = Os.isFamily(Os.FAMILY_WINDOWS) ? "mvn.cmd" : "mvn"
+
+ // Set the project.version property in the pom.xml
+ def libraryProject = project.findProject(':library')
+ def version = libraryProject.version
+
+ // Create the command with proper arguments
+ def cmd = [
+ mvnExecutable,
+ "deploy",
+ "-f", "${project.rootDir}/library/pom.xml",
+ "-s", "${project.rootDir}/.mvn/maven-settings.xml",
+ "-Dproject.version=${version}",
+ "--batch-mode"
+ ]
- logger.lifecycle("Publishing to Central Portal API with publishing type: ${publishingType}")
+ // Execute the Maven command
+ def processBuilder = new ProcessBuilder(cmd)
+ // Pass the environment variables to the process
+ def env = processBuilder.environment()
+ env.put("SONATYPE_NEXUS_USERNAME", username)
+ env.put("SONATYPE_NEXUS_PASSWORD", token)
- logger.lifecycle("Publishing to Central Portal API completed successfully")
+ // For signing
+ def keyId = System.getenv('SIGNING_KEY_ID') ?: project.findProperty('signing.keyId')
+ def password = System.getenv('SIGNING_KEY_PASSWORD') ?: project.findProperty('signing.password')
+ def secretKeyRingFile = System.getenv('SIGNING_KEY_FILE') ?: project.findProperty('signing.secretKeyRingFile')
+
+ if (keyId && password && secretKeyRingFile) {
+ env.put("SIGNING_KEY_ID", keyId)
+ env.put("SIGNING_KEY_PASSWORD", password)
+ env.put("SIGNING_KEY_FILE", secretKeyRingFile)
+ }
+
+ // Redirect output to the Gradle logger
+ processBuilder.redirectErrorStream(true)
+ def process = processBuilder.start()
+
+ // Read the output
+ def reader = new BufferedReader(new InputStreamReader(process.getInputStream()))
+ String line
+ while ((line = reader.readLine()) != null) {
+ logger.lifecycle(line)
+ }
+
+ // Wait for the process to complete
+ def exitCode = process.waitFor()
+
+ if (exitCode != 0) {
+ throw new GradleException("Maven publish failed with exit code: ${exitCode}")
+ }
+
+ logger.lifecycle("====================================================================")
+ logger.lifecycle("Publishing to Maven Central completed successfully")
+ logger.lifecycle("NOTE: After successful publishing, it may take several hours for")
+ logger.lifecycle("the artifacts to appear on Maven Central and search indexes.")
+ logger.lifecycle("====================================================================")
}
}
tasks.register('checkCentralPortalDeployment') {
group = 'publishing'
- description = 'Checks the status of deployments in Sonatype Central Portal'
+ description = 'Checks the status of deployments in Sonatype Central Portal using Maven plugin'
doLast {
- // Look for token in multiple places for flexibility
+ logger.lifecycle("====================================================================")
+ logger.lifecycle("Checking deployment status in Central Portal")
+ logger.lifecycle("====================================================================")
+
+ // Make sure we have credentials
+ def username = System.getenv('SONATYPE_NEXUS_USERNAME') ?:
+ findProperty('sonatypeUsername') ?:
+ project.hasProperty('sonatypeUsername') ? project.getProperty('sonatypeUsername') : null
+
def token = System.getenv('SONATYPE_NEXUS_PASSWORD') ?:
- findProperty('sonatypePassword') ?:
- project.hasProperty('sonatypePassword') ? project.getProperty('sonatypePassword') : null
+ findProperty('sonatypePassword') ?:
+ project.hasProperty('sonatypePassword') ? project.getProperty('sonatypePassword') : null
- if (!token) {
- logger.warn("SONATYPE_NEXUS_PASSWORD environment variable or sonatypePassword property not set")
- logger.warn("Using 'demo-token' for testing only. For production, set SONATYPE_NEXUS_PASSWORD")
- token = "demo-token" // Only for testing - will not work in production!
+ if (!username || !token) {
+ throw new GradleException("SONATYPE_NEXUS_USERNAME and SONATYPE_NEXUS_PASSWORD environment variables or properties are required")
}
- // The API endpoint
- def apiUrl = "https://central.sonatype.com/api/v1/publisher/status"
+ // Get library project information
+ def libraryProject = project.findProject(':library')
+ def groupId = libraryProject.group
+ def artifactId = libraryProject.name
+ def version = libraryProject.version
- logger.lifecycle("Checking deployment status in Central Portal")
+ // Ensure we have Maven available
+ def mvnExecutable = Os.isFamily(Os.FAMILY_WINDOWS) ? "mvn.cmd" : "mvn"
+
+ // Create the command to check status
+ def cmd = [
+ mvnExecutable,
+ "org.sonatype.central:central-publishing-maven-plugin:status",
+ "-f", "${project.rootDir}/library/pom.xml",
+ "-s", "${project.rootDir}/.mvn/maven-settings.xml",
+ "-Dcentral.groupId=${groupId}",
+ "-Dcentral.artifactId=${artifactId}",
+ "-Dcentral.version=${version}",
+ "--batch-mode"
+ ]
+
+ // Execute the Maven command
+ def processBuilder = new ProcessBuilder(cmd)
+ // Pass the environment variables to the process
+ def env = processBuilder.environment()
+ env.put("SONATYPE_NEXUS_USERNAME", username)
+ env.put("SONATYPE_NEXUS_PASSWORD", token)
- logger.lifecycle("Deployment status check completed")
+ // Redirect output to the Gradle logger
+ processBuilder.redirectErrorStream(true)
+ def process = processBuilder.start()
+
+ // Read the output
+ def reader = new BufferedReader(new InputStreamReader(process.getInputStream()))
+ String line
+ while ((line = reader.readLine()) != null) {
+ logger.lifecycle(line)
+ }
+
+ // Wait for the process to complete
+ def exitCode = process.waitFor()
+
+ if (exitCode != 0) {
+ logger.warn("Maven status check failed with exit code: ${exitCode}")
+ }
+
+ logger.lifecycle("====================================================================")
+ logger.lifecycle("NOTE: After successful publishing, it may take several hours for")
+ logger.lifecycle("the artifacts to appear on Maven Central (https://repo.maven.apache.org/maven2)")
+ logger.lifecycle("and search indexes like mvnrepository.com or search.maven.org.")
+ logger.lifecycle("====================================================================")
}
}
@@ -107,16 +229,53 @@ subprojects {
group = "com.paypal.messages"
}
+
+// Override the version for the publication
+afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ groupId = "com.paypal.messages"
+ version = modules.sdkVersionName
+ artifactId = "paypal-messages"
+ }
+ }
+ }
+}
+
+// Configure duplicate handling for ZIP tasks
+tasks.withType(Zip) {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
//./gradlew -PversionParam=0.0.1 changeReleaseVersion
tasks.register('changeReleaseVersion') {
- doLast {
- def topLevelGradleFile = file('./build.gradle')
- def topLevelGradleFileText = topLevelGradleFile.getText('UTF-8')
- def useSnapshot = System.getenv('USE_SNAPSHOT')
- def snapshotParam = useSnapshot == "true" || useSnapshot == true ? "-SNAPSHOT" : ""
-
- def updatedScript =
- topLevelGradleFileText.replaceFirst(/("sdkVersionName"\s*: )".*",/, '$1"' + versionParam + snapshotParam + '",')
- topLevelGradleFile.write(updatedScript, 'UTF-8')
- }
-}
+ doLast {
+ def newVersion = versionParam + (System.getenv('USE_SNAPSHOT') == "true" ? "-SNAPSHOT" : "")
+
+ // Update build.gradle
+ def topLevelGradleFile = file('./build.gradle')
+ def topLevelGradleFileText = topLevelGradleFile.getText('UTF-8')
+ def updatedGradleScript = topLevelGradleFileText.replaceFirst(
+ /("sdkVersionName"\s*: )".*",/,
+ '$1"' + newVersion + '",'
+ )
+ topLevelGradleFile.write(updatedGradleScript, 'UTF-8')
+
+ // Update library/pom.xml
+ def pomFile = file('./library/pom.xml')
+ if (pomFile.exists()) {
+ def pomText = pomFile.getText('UTF-8')
+ def updatedPomText = pomText.replaceFirst(
+ /.*?<\/version>/,
+ "" + newVersion + ""
+ )
+ pomFile.write(updatedPomText, 'UTF-8')
+ println("Updated version in library/pom.xml to " + newVersion)
+ } else {
+ println("Warning: library/pom.xml not found")
+ }
+
+ println("Version updated to " + newVersion)
+ }
+}
\ No newline at end of file
diff --git a/demo/README.md b/demo/README.md
new file mode 100644
index 00000000..824230bd
--- /dev/null
+++ b/demo/README.md
@@ -0,0 +1,17 @@
+# PayPal Messages Demo
+
+This demo showcases the different ways to integrate the PayPal Messages component into an Android application.
+
+## Structure
+
+The demo is structured as follows:
+
+- `DemoActivity`: The main entry point of the demo application. It uses a `TabLayout` and `ViewPager2` to display the different integration methods.
+- `SectionsPagerAdapter`: An adapter that provides the fragments for the `ViewPager2`.
+- `XmlFragment`: A fragment that demonstrates how to use the `PayPalMessageView` in an XML layout.
+- `JetpackFragment`: A fragment that demonstrates how to use the `PayPalMessageView` in a Jetpack Compose UI.
+- `JetpackComposableFragment`: A fragment that demonstrates how to use the `PayPalComposableMessage` in a Jetpack Compose UI.
+
+## How to Run
+
+To run the demo, simply build and run the `demo` module on an Android device or emulator.
\ No newline at end of file
diff --git a/demo/build.gradle b/demo/build.gradle
index 417311dc..80f40323 100644
--- a/demo/build.gradle
+++ b/demo/build.gradle
@@ -45,6 +45,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
matchingFallbacks = ['release']
}
+ // Debug build with minification enabled for easy testing
+ debugWithMinifyEnabled {
+ debuggable true
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ matchingFallbacks = ['debug']
+ }
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@@ -68,7 +75,9 @@ dependencies {
implementation platform('androidx.compose:compose-bom:2023.05.01')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.activity:activity-compose:1.5.1'
-
+ implementation 'com.squareup.okhttp3:okhttp:4.8.0'
+ implementation 'com.google.code.gson:gson:2.9.1'
+
// Add constraints to ensure compatible versions
constraints {
implementation('androidx.activity:activity-ktx') {
@@ -87,7 +96,7 @@ dependencies {
}
}
}
-
+
// Compose dependencies - versions managed by BOM
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
@@ -97,15 +106,16 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation project(':library')
+ // implementation files('libs/library-release.aar')
implementation 'com.google.android.material:material:1.10.0'
-
+
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2023.05.01')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
-
+
// Debug implementations for Compose tooling
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
diff --git a/demo/proguard-rules.pro b/demo/proguard-rules.pro
index 9fb71774..b7c7725a 100644
--- a/demo/proguard-rules.pro
+++ b/demo/proguard-rules.pro
@@ -64,4 +64,31 @@
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
--dontwarn org.openjsse.**
\ No newline at end of file
+-dontwarn org.openjsse.**
+
+# Keep OkHttp classes
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+-dontwarn okhttp3.**
+-dontwarn okio.**
+
+# Keep Gson classes
+-keep class com.google.gson.** { *; }
+-keep class * implements com.google.gson.TypeAdapterFactory
+-keep class * implements com.google.gson.JsonSerializer
+-keep class * implements com.google.gson.JsonDeserializer
+-keepattributes Signature
+
+# Keep Kotlin Coroutines
+-keep class kotlinx.coroutines.** { *; }
+-keepclassmembernames class kotlinx.** {
+ volatile ;
+}
+-keepclassmembers class kotlin.coroutines.** { *; }
+-keep class kotlin.coroutines.** { *; }
+
+# Preserve the special static methods that are required in all enumeration classes
+-keepclassmembers class * extends java.lang.Enum {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
\ No newline at end of file
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
index 159ebd4a..67138299 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demo/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
-
-
@@ -33,15 +24,5 @@
-
-
-
-
-
-
-
diff --git a/demo/src/main/java/com/paypal/messagesdemo/DemoActivity.kt b/demo/src/main/java/com/paypal/messagesdemo/DemoActivity.kt
new file mode 100644
index 00000000..d74d1e08
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/DemoActivity.kt
@@ -0,0 +1,24 @@
+package com.paypal.messagesdemo
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.viewpager.widget.ViewPager
+import com.google.android.material.tabs.TabLayout
+import com.paypal.messagesdemo.databinding.ActivityDemoBinding
+import com.paypal.messagesdemo.ui.main.SectionsPagerAdapter
+
+class DemoActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityDemoBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityDemoBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val sectionsPagerAdapter = SectionsPagerAdapter(this, supportFragmentManager)
+ val viewPager: ViewPager = binding.viewPager
+ viewPager.adapter = sectionsPagerAdapter
+ val tabs: TabLayout = binding.tabs
+ tabs.setupWithViewPager(viewPager)
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/JetpackActivity.kt b/demo/src/main/java/com/paypal/messagesdemo/JetpackActivity.kt
deleted file mode 100644
index bc049e52..00000000
--- a/demo/src/main/java/com/paypal/messagesdemo/JetpackActivity.kt
+++ /dev/null
@@ -1,364 +0,0 @@
-package com.paypal.messagesdemo
-
-import android.os.Bundle
-import android.util.Log
-import android.widget.Toast
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.viewinterop.AndroidView
-import com.paypal.messages.PayPalMessageView
-import com.paypal.messages.config.PayPalEnvironment
-import com.paypal.messages.config.PayPalMessageOfferType
-import com.paypal.messages.config.message.PayPalMessageConfig
-import com.paypal.messages.config.message.PayPalMessageData
-import com.paypal.messages.config.message.PayPalMessageEventsCallbacks
-import com.paypal.messages.config.message.PayPalMessageViewStateCallbacks
-import com.paypal.messages.config.message.style.PayPalMessageAlignment
-import com.paypal.messages.config.message.style.PayPalMessageColor
-import com.paypal.messages.config.message.style.PayPalMessageLogoType
-import com.paypal.messages.io.Api
-import com.paypal.messagesdemo.composables.CircularIndicator
-import com.paypal.messagesdemo.composables.InputField
-import com.paypal.messagesdemo.ui.BasicTheme
-
-/**
- * Converts a string to sentence case (first letter capitalized, rest lowercase)
- */
-
-fun toSentenceCase(input: String): String {
- return input.lowercase().replaceFirstChar { it.titlecase() }
-}
-
-class JetpackActivity : ComponentActivity() {
- private val TAG = "PPM:JetpackActivity"
- private val environment = PayPalEnvironment.SANDBOX
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContent {
- BasicTheme {
- val context = LocalContext.current
-
- var clientId: String by remember { mutableStateOf(getString(R.string.client_id)) }
-
- // Style Color
- var backgroundColor by remember { mutableStateOf(Color.White) }
- val colorGroupOptions = listOf(
- PayPalMessageColor.BLACK,
- PayPalMessageColor.WHITE,
- PayPalMessageColor.MONOCHROME,
- PayPalMessageColor.GRAYSCALE,
- )
- var messageColor by remember { mutableStateOf(colorGroupOptions[0]) }
-
- // Style Logo
- val logoGroupOptions = listOf(
- PayPalMessageLogoType.PRIMARY,
- PayPalMessageLogoType.INLINE,
- PayPalMessageLogoType.ALTERNATIVE,
- PayPalMessageLogoType.NONE,
- )
-
- var messageLogo by remember { mutableStateOf(logoGroupOptions[0]) }
-
- // Style Alignment
- val alignmentGroupOptions = listOf(
- PayPalMessageAlignment.LEFT,
- PayPalMessageAlignment.CENTER,
- PayPalMessageAlignment.RIGHT,
- )
- var messageAlignment by remember { mutableStateOf(alignmentGroupOptions[0]) }
-
- val offerGroupOptions = listOf(
- "Short Term",
- "Long Term",
- "Pay In 1",
- "Credit",
- )
- var offerType: String? by remember { mutableStateOf(null) }
-
- var amount: String by remember { mutableStateOf("") }
- var buyerCountry: String? by remember { mutableStateOf(null) }
- var stageTag: String by remember { mutableStateOf("") }
- var ignoreCache: Boolean by remember { mutableStateOf(false) }
- var devTouchpoint: Boolean by remember { mutableStateOf(false) }
- var buttonEnabled: Boolean by remember { mutableStateOf(true) }
-
- // State for the PayPal message
- var progressBar by remember { mutableStateOf(false) }
-
- // Create and configure the PayPal message view
- // This is the standard, recommended way to use PayPal Messages
- // The library will automatically handle clicks and show modals without any additional code
- val messageView = remember {
- PayPalMessageView(
- context,
- config = PayPalMessageConfig(
- // Configure the message data
- data = PayPalMessageData(clientID = clientId, environment = environment),
- // Optional: Add callbacks to receive state updates
- viewStateCallbacks = PayPalMessageViewStateCallbacks(
- onLoading = {
- progressBar = true
- buttonEnabled = false
- Toast.makeText(context, "Loading Content...", Toast.LENGTH_SHORT).show()
- },
- onError = {
- Log.d(TAG, "onError $it")
- progressBar = false
- buttonEnabled = true
- Toast.makeText(context, it.javaClass.toString() + ":" + it.message, Toast.LENGTH_LONG).show()
- },
- onSuccess = {
- Log.d(TAG, "onSuccess")
- progressBar = false
- buttonEnabled = true
- Toast.makeText(context, "Success Getting Content", Toast.LENGTH_SHORT).show()
- },
- ),
- // Optional: Add callbacks for click events
- // Note: You don't need to show the modal manually - the library handles it automatically!
- eventsCallbacks = PayPalMessageEventsCallbacks(
- onClick = {
- // This is called when the message is clicked
- // No need to manually show any modals - the library does this for you
- Log.d(TAG, "Message clicked callback invoked")
- },
- onApply = {
- Log.d(TAG, "Apply clicked in modal")
- Toast.makeText(context, "Apply clicked in modal", Toast.LENGTH_SHORT).show()
- },
- ),
- ),
- )
- }
-
- fun updateMessageData() {
- messageView.clientID = clientId
-
- backgroundColor = if (messageColor === PayPalMessageColor.WHITE) Color.Black else Color.White
- messageView.color = messageColor
- messageView.logoType = messageLogo
- messageView.textAlignment = messageAlignment
-
- messageView.offerType = when (offerType) {
- offerGroupOptions[0] -> PayPalMessageOfferType.PAY_LATER_SHORT_TERM
- offerGroupOptions[1] -> PayPalMessageOfferType.PAY_LATER_LONG_TERM
- offerGroupOptions[2] -> PayPalMessageOfferType.PAY_LATER_PAY_IN_1
- offerGroupOptions[3] -> PayPalMessageOfferType.PAYPAL_CREDIT_NO_INTEREST
- else -> null
- }
-
- messageView.amount = amount.takeIf { it.isNotBlank() }?.toDouble()
-
- messageView.buyerCountry = buyerCountry?.takeIf { it.isNotBlank() }
-
- Api.stageTag = stageTag
- Api.ignoreCache = ignoreCache
- Api.devTouchpoint = devTouchpoint
- }
-
- fun resetButton() {
- messageColor = colorGroupOptions[0]
- messageLogo = logoGroupOptions[0]
- messageAlignment = alignmentGroupOptions[0]
-
- offerType = null
- amount = ""
- buyerCountry = ""
- stageTag = ""
- ignoreCache = false
- devTouchpoint = false
-
- updateMessageData()
- }
-
- // A surface container using the 'background' color from the theme
- Surface(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier
- .fillMaxSize()
- .padding(start = 12.dp, end = 12.dp),
- ) {
- Column(
- modifier = Modifier.verticalScroll(state = rememberScrollState()),
- ) {
- Text(
- text = "Message Configuration",
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(top = 8.dp),
- )
-
- InputField(
- text = "Client ID",
- value = clientId,
- onChange = {
- clientId = it
- },
- padding = 16.dp,
- )
-
- Text(
- text = "Style Options",
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .width(125.dp)
- .height(intrinsicSize = IntrinsicSize.Max),
- )
-
- RadioOptions(
- logoGroupOptions = logoGroupOptions,
- selected = messageLogo,
- onSelected = { text: PayPalMessageLogoType ->
- messageLogo = text
- },
- )
-
- RadioOptions(
- logoGroupOptions = colorGroupOptions,
- selected = messageColor,
- onSelected = { text: PayPalMessageColor ->
- messageColor = text
- },
- )
-
- RadioOptions(
- logoGroupOptions = alignmentGroupOptions,
- selected = messageAlignment,
- onSelected = { text: PayPalMessageAlignment ->
- messageAlignment = text
- },
- )
-
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- text = "Offer Type",
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .padding(top = 8.dp)
- .width(125.dp)
- .height(intrinsicSize = IntrinsicSize.Max),
- )
- FilledButton(text = "Clear", onClick = { offerType = null }, buttonEnabled = buttonEnabled)
- }
-
- OfferOptions(
- offerGroupOptions = offerGroupOptions,
- selected = offerType,
- onSelected = { text: String ->
- offerType = text
- },
- )
-
- InputField(
- text = "Amount",
- value = amount,
- onChange = { amount = it },
- keyboardType = KeyboardType.Number,
- )
-
- InputField(
- text = "Buyer Country",
- value = buyerCountry ?: "",
- onChange = { buyerCountry = it },
- )
-
- InputField(
- text = "Stage Tag",
- value = stageTag,
- onChange = { stageTag = it },
- )
-
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- ) {
- SwitchOption(
- checked = ignoreCache,
- onChange = { ignoreCache = it },
- text = " Ignore Cache",
- )
- SwitchOption(
- checked = devTouchpoint,
- onChange = { devTouchpoint = it },
- text = "Dev Touchpoint",
- )
- }
-
- // Show loading indicator when messages are loading
- CircularIndicator(progressBar = progressBar)
-
- // This is the recommended way to use PayPal Messages in Jetpack Compose
- // Simply create a PayPalMessageView and place it in your Compose UI using AndroidView
- // The library handles all click events and modal display automatically!
- AndroidView(
- modifier = Modifier
- .padding(top = 16.dp, bottom = 32.dp, start = 8.dp, end = 8.dp)
- .background(color = backgroundColor)
- .height(40.dp)
- .fillMaxWidth(),
- factory = {
- // The messageView is created and configured earlier in this file
- messageView
- },
- update = { view ->
- // Add visual feedback when touched - ripple effect
- view.foreground = android.graphics.drawable.RippleDrawable(
- android.content.res.ColorStateList.valueOf(android.graphics.Color.parseColor("#30000000")),
- null,
- android.graphics.drawable.ColorDrawable(android.graphics.Color.WHITE),
- )
-
- // Note: We don't need to set any click listeners here.
- // The PayPalMessageView already handles clicks and shows the modal automatically.
- },
- )
-
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth(),
- ) {
- FilledButton(text = "Reset", onClick = { resetButton() }, buttonEnabled = buttonEnabled)
- FilledButton(text = "Submit", onClick = { updateMessageData() }, buttonEnabled = buttonEnabled)
- }
- }
- }
- }
- }
- }
-}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableActivity.kt b/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableActivity.kt
deleted file mode 100644
index 0b1519bc..00000000
--- a/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableActivity.kt
+++ /dev/null
@@ -1,312 +0,0 @@
-package com.paypal.messagesdemo
-
-import android.app.Activity
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
-import android.widget.Toast
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
-import androidx.compose.material3.Divider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.viewinterop.AndroidView
-import com.paypal.messages.PayPalComposableMessage
-import com.paypal.messages.PayPalMessageView
-import com.paypal.messages.PayPalModalActivity
-import com.paypal.messages.config.PayPalEnvironment
-import com.paypal.messages.config.PayPalMessageOfferType
-import com.paypal.messages.config.message.PayPalMessageConfig
-import com.paypal.messages.config.message.PayPalMessageData
-import com.paypal.messages.config.message.PayPalMessageEventsCallbacks
-import com.paypal.messages.config.message.PayPalMessageViewStateCallbacks
-import com.paypal.messagesdemo.composables.CircularIndicator
-import com.paypal.messagesdemo.composables.InputField
-import com.paypal.messagesdemo.ui.BasicTheme
-import java.util.UUID
-
-class JetpackComposableActivity : ComponentActivity() {
- private val TAG = "PPM:JetpackComposableActivity"
- private val environment = PayPalEnvironment.SANDBOX
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContent {
- BasicTheme {
- val context = LocalContext.current
-
- // State variables for modal configuration
- var clientId: String by remember { mutableStateOf(getString(R.string.client_id)) }
- var amount: String by remember { mutableStateOf("100.00") }
- var buyerCountry: String by remember { mutableStateOf("US") }
- var offerType: String? by remember { mutableStateOf(PayPalMessageOfferType.PAY_LATER_SHORT_TERM.name) }
-
- // Loading state
- var isLoading by remember { mutableStateOf(false) }
-
- // No longer need a state for the modal since we use an Activity
-
- // Surface container
- Surface(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
- ) {
- Column(
- modifier = Modifier.verticalScroll(state = rememberScrollState()),
- ) {
- Text(
- text = "PayPal Custom Modal Demo",
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(vertical = 16.dp),
- )
-
- Text(
- text = "This demo shows how to use the PayPalComposableModal composable directly in your Compose UI.",
- modifier = Modifier.padding(bottom = 16.dp),
- )
-
- InputField(
- text = "Client ID",
- value = clientId,
- onChange = { clientId = it },
- padding = 8.dp,
- )
-
- InputField(
- text = "Amount",
- value = amount,
- onChange = { amount = it },
- keyboardType = KeyboardType.Number,
- padding = 8.dp,
- )
-
- InputField(
- text = "Buyer Country",
- value = buyerCountry,
- onChange = { buyerCountry = it },
- padding = 8.dp,
- )
-
- // Offer Type selection
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- ) {
- Text(
- text = "Offer Type:",
- modifier = Modifier.align(Alignment.CenterVertically),
- )
-
- Button(
- onClick = {
- offerType = PayPalMessageOfferType.PAY_LATER_SHORT_TERM.name
- },
- ) {
- Text("Short Term")
- }
-
- Button(
- onClick = {
- offerType = PayPalMessageOfferType.PAY_LATER_LONG_TERM.name
- },
- ) {
- Text("Long Term")
- }
- }
-
- // PayPal Message View - shows a clickable message
- Text(
- text = "PayPal Message Implementations:",
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
- )
-
- // Legacy Implementation
- Text(
- text = "1. Legacy Implementation (AndroidView):",
- fontWeight = FontWeight.Medium,
- modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
- )
-
- // Using the actual PayPalMessageView that can be clicked to show the modal
- var backgroundColor by remember { mutableStateOf(Color.White) }
- val messageView = remember {
- PayPalMessageView(
- context,
- config = PayPalMessageConfig(
- // Configure the message data
- data = PayPalMessageData(clientID = clientId, environment = environment),
- // Optional: Add callbacks to receive state updates
- viewStateCallbacks = PayPalMessageViewStateCallbacks(
- onLoading = {
- Log.d(TAG, "Loading message content...")
- isLoading = true
- },
- onError = {
- Log.d(TAG, "Error loading message: $it")
- isLoading = false
- Toast.makeText(context, "Error: $it", Toast.LENGTH_SHORT).show()
- },
- onSuccess = {
- Log.d(TAG, "Message loaded successfully")
- isLoading = false
- },
- ),
- // Optional: Add callbacks for click events
- eventsCallbacks = PayPalMessageEventsCallbacks(
- onClick = {
- Log.d(TAG, "Message clicked callback invoked")
- },
- onApply = {
- Log.d(TAG, "Apply clicked in modal")
- Toast.makeText(context, "Apply clicked in modal", Toast.LENGTH_SHORT).show()
- },
- ),
- ),
- )
- }
-
- // Update message configuration when inputs change
- messageView.amount = amount.toDoubleOrNull()
- messageView.buyerCountry = buyerCountry
- messageView.offerType = offerType?.let { PayPalMessageOfferType.valueOf(it) }
-
- // Render the PayPal message view in the Compose UI
- AndroidView(
- modifier = Modifier
- .padding(vertical = 8.dp)
- .background(color = backgroundColor)
- .height(40.dp)
- .fillMaxWidth(),
- factory = { messageView },
- update = { view ->
- // Add visual feedback when touched
- view.foreground = android.graphics.drawable.RippleDrawable(
- android.content.res.ColorStateList.valueOf(android.graphics.Color.parseColor("#30000000")),
- null,
- android.graphics.drawable.ColorDrawable(android.graphics.Color.WHITE),
- )
- },
- )
-
- // Show loading indicator when messages are loading
- CircularIndicator(progressBar = isLoading)
-
- Divider(modifier = Modifier.padding(vertical = 16.dp))
-
- // Composable Implementation
- Text(
- text = "2. Composable Implementation:",
- fontWeight = FontWeight.Medium,
- modifier = Modifier.padding(bottom = 4.dp),
- )
-
- // Use our new composable for a cleaner integration
- // Note: This composable implementation might not display properly due to compatibility issues
- // The fallback text will be shown to indicate this
- PayPalComposableMessage(
- clientId = clientId,
- amount = amount.toDoubleOrNull(),
- buyerCountry = buyerCountry,
- offerType = offerType,
- environment = environment,
- onLoading = {
- Log.d(TAG, "Composable message loading...")
- isLoading = true
- },
- onError = { error ->
- Log.d(TAG, "Composable message error: $error")
- isLoading = false
- Toast.makeText(context, "Error: ${error.message}", Toast.LENGTH_SHORT).show()
- },
- onSuccess = {
- Log.d(TAG, "Composable message loaded successfully")
- isLoading = false
- },
- onClick = {
- Log.d(TAG, "Composable message clicked")
- },
- onApply = {
- Log.d(TAG, "Apply clicked in composable message modal")
- Toast.makeText(context, "Apply clicked in modal", Toast.LENGTH_SHORT).show()
- },
- modifier = Modifier
- .padding(vertical = 8.dp)
- .background(Color.White)
- .height(40.dp)
- .fillMaxWidth(),
- showFallbackIndicator = true,
- )
-
- // Direct modal button (alternative to clicking the message)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 24.dp),
- contentAlignment = Alignment.Center,
- ) {
- Button(
- onClick = {
- Log.d(TAG, "Showing custom modal with amount: $amount, country: $buyerCountry, offer: $offerType")
-
- // Create intent for the PayPalModalActivity
- val intent = Intent(context, PayPalModalActivity::class.java).apply {
- putExtra("CLIENT_ID", clientId)
- putExtra("INSTANCE_ID", UUID.randomUUID().toString())
- amount.toDoubleOrNull()?.let { amountValue ->
- putExtra("AMOUNT", amountValue)
- }
- putExtra("BUYER_COUNTRY", buyerCountry)
- putExtra("OFFER_TYPE", offerType)
- }
-
- // Start the activity to show the modal
- context.startActivity(intent)
-
- // Add animation override if using an Activity context
- if (context is Activity) {
- context.overridePendingTransition(android.R.anim.fade_in, 0)
- }
- },
- modifier = Modifier.fillMaxWidth(0.8f),
- ) {
- Text("Show Custom Modal Directly", fontSize = 16.sp)
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableFragment.kt b/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableFragment.kt
new file mode 100644
index 00000000..c45557f9
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/JetpackComposableFragment.kt
@@ -0,0 +1,315 @@
+package com.paypal.messagesdemo
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.fragment.app.Fragment
+import com.paypal.messages.PayPalComposableMessage
+import com.paypal.messages.PayPalMessageView
+import com.paypal.messages.PayPalModalActivity
+import com.paypal.messages.config.PayPalEnvironment
+import com.paypal.messages.config.PayPalMessageOfferType
+import com.paypal.messages.config.message.PayPalMessageConfig
+import com.paypal.messages.config.message.PayPalMessageData
+import com.paypal.messages.config.message.PayPalMessageEventsCallbacks
+import com.paypal.messages.config.message.PayPalMessageViewStateCallbacks
+import com.paypal.messagesdemo.composables.CircularIndicator
+import com.paypal.messagesdemo.composables.InputField
+import com.paypal.messagesdemo.ui.BasicTheme
+import com.paypal.messagesdemo.utils.FoolproofToastHelper
+import java.util.UUID
+
+class JetpackComposableFragment : Fragment() {
+ private val TAG = "PPM:JetpackComposableFragment"
+ private val environment = PayPalEnvironment.SANDBOX
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ return ComposeView(requireContext()).apply {
+ setContent {
+ BasicTheme {
+ val context = LocalContext.current
+
+ // State variables for modal configuration
+ var clientId: String by remember { mutableStateOf(getString(R.string.client_id)) }
+ var amount: String by remember { mutableStateOf("100.00") }
+ var buyerCountry: String by remember { mutableStateOf("US") }
+ var offerType: String? by remember { mutableStateOf(PayPalMessageOfferType.PAY_LATER_SHORT_TERM.name) }
+
+ // Loading state
+ var isLoading by remember { mutableStateOf(false) }
+
+ // Surface container
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ ) {
+ Column(
+ modifier = Modifier.verticalScroll(state = rememberScrollState()),
+ ) {
+ Text(
+ text = "PayPal Custom Modal Demo",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+
+ Text(
+ text = "This demo shows how to use the PayPalComposableModal composable directly in your Compose UI.",
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+
+ InputField(
+ text = "Client ID",
+ value = clientId,
+ onChange = { clientId = it },
+ padding = 8.dp,
+ )
+
+ InputField(
+ text = "Amount",
+ value = amount,
+ onChange = { amount = it },
+ keyboardType = KeyboardType.Number,
+ padding = 8.dp,
+ )
+
+ InputField(
+ text = "Buyer Country",
+ value = buyerCountry,
+ onChange = { buyerCountry = it },
+ padding = 8.dp,
+ )
+
+ // Offer Type selection
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ ) {
+ Text(
+ text = "Offer Type:",
+ modifier = Modifier.align(Alignment.CenterVertically),
+ )
+
+ Button(
+ onClick = {
+ offerType = PayPalMessageOfferType.PAY_LATER_SHORT_TERM.name
+ },
+ ) {
+ Text("Short Term")
+ }
+
+ Button(
+ onClick = {
+ offerType = PayPalMessageOfferType.PAY_LATER_LONG_TERM.name
+ },
+ ) {
+ Text("Long Term")
+ }
+ }
+
+ // PayPal Message View - shows a clickable message
+ Text(
+ text = "PayPal Message Implementations:",
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
+ )
+
+ // Legacy Implementation
+ Text(
+ text = "1. Legacy Implementation (AndroidView):",
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
+ )
+
+ // Using the actual PayPalMessageView that can be clicked to show the modal
+ var backgroundColor by remember { mutableStateOf(Color.White) }
+ val messageView = remember {
+ PayPalMessageView(
+ context,
+ config = PayPalMessageConfig(
+ // Configure the message data
+ data = PayPalMessageData(clientID = clientId, environment = environment),
+ // Optional: Add callbacks to receive state updates
+ viewStateCallbacks = PayPalMessageViewStateCallbacks(
+ onLoading = {
+ Log.d(TAG, "Loading message content...")
+ isLoading = true
+ },
+ onError = {
+ Log.d(TAG, "Error loading message: $it")
+ isLoading = false
+ FoolproofToastHelper.showToast(context, "Error: $it")
+ },
+ onSuccess = {
+ Log.d(TAG, "Message loaded successfully")
+ isLoading = false
+ },
+ ),
+ // Optional: Add callbacks for click events
+ eventsCallbacks = PayPalMessageEventsCallbacks(
+ onClick = {
+ Log.d(TAG, "Message clicked callback invoked")
+ },
+ onApply = {
+ Log.d(TAG, "Apply clicked in modal")
+ FoolproofToastHelper.showToast(context, "Apply clicked in modal")
+ },
+ ),
+ ),
+ )
+ }
+
+ // Update message configuration when inputs change
+ messageView.amount = amount.toDoubleOrNull()
+ messageView.buyerCountry = buyerCountry
+ messageView.offerType = offerType?.let { PayPalMessageOfferType.valueOf(it) }
+
+ // Render the PayPal message view in the Compose UI
+ AndroidView(
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .background(color = backgroundColor)
+ .height(40.dp)
+ .fillMaxWidth(),
+ factory = { messageView },
+ update = { view ->
+ // Add visual feedback when touched
+ view.foreground = android.graphics.drawable.RippleDrawable(
+ android.content.res.ColorStateList.valueOf(android.graphics.Color.parseColor("#30000000")),
+ null,
+ android.graphics.drawable.ColorDrawable(android.graphics.Color.WHITE),
+ )
+ },
+ )
+
+ // Show loading indicator when messages are loading
+ CircularIndicator(progressBar = isLoading)
+
+ Divider(modifier = Modifier.padding(vertical = 16.dp))
+
+ // Composable Implementation
+ Text(
+ text = "2. Composable Implementation:",
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+
+ // Use our new composable for a cleaner integration
+ PayPalComposableMessage(
+ clientId = clientId,
+ amount = amount.toDoubleOrNull(),
+ buyerCountry = buyerCountry,
+ offerType = offerType,
+ environment = environment,
+ onLoading = {
+ Log.d(TAG, "Composable message loading...")
+ isLoading = true
+ },
+ onError = { error ->
+ Log.d(TAG, "Composable message error: $error")
+ isLoading = false
+ FoolproofToastHelper.showToast(context, "Error: ${error.message}")
+ },
+ onSuccess = {
+ Log.d(TAG, "Composable message loaded successfully")
+ isLoading = false
+ },
+ onClick = {
+ Log.d(TAG, "Composable message clicked")
+ },
+ onApply = {
+ Log.d(TAG, "Apply clicked in composable message modal")
+ FoolproofToastHelper.showToast(context, "Apply clicked in modal")
+ },
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .background(Color.White)
+ .height(40.dp)
+ .fillMaxWidth(),
+ showFallbackIndicator = true,
+ )
+
+ // Direct modal button (alternative to clicking the message)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Button(
+ onClick = {
+ Log.d(TAG, "Showing custom modal with amount: $amount, country: $buyerCountry, offer: $offerType")
+
+ // Create intent for the PayPalModalActivity
+ val intent = Intent(context, PayPalModalActivity::class.java).apply {
+ putExtra("CLIENT_ID", clientId)
+ putExtra("INSTANCE_ID", UUID.randomUUID().toString())
+ amount.toDoubleOrNull()?.let { amountValue ->
+ putExtra("AMOUNT", amountValue)
+ }
+ putExtra("BUYER_COUNTRY", buyerCountry)
+ putExtra("OFFER_TYPE", offerType)
+ }
+
+ // Start the activity to show the modal
+ context.startActivity(intent)
+
+ // Add animation override if using an Activity context
+ if (context is Activity) {
+ context.overridePendingTransition(android.R.anim.fade_in, 0)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(0.8f),
+ ) {
+ Text("Show Custom Modal Directly", fontSize = 16.sp)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/JetpackFragment.kt b/demo/src/main/java/com/paypal/messagesdemo/JetpackFragment.kt
new file mode 100644
index 00000000..8bdca8b9
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/JetpackFragment.kt
@@ -0,0 +1,349 @@
+package com.paypal.messagesdemo
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.fragment.app.Fragment
+import com.paypal.messages.PayPalMessageView
+import com.paypal.messages.config.PayPalEnvironment
+import com.paypal.messages.config.PayPalMessageOfferType
+import com.paypal.messages.config.message.PayPalMessageConfig
+import com.paypal.messages.config.message.PayPalMessageData
+import com.paypal.messages.config.message.PayPalMessageEventsCallbacks
+import com.paypal.messages.config.message.PayPalMessageViewStateCallbacks
+import com.paypal.messages.config.message.style.PayPalMessageAlignment
+import com.paypal.messages.config.message.style.PayPalMessageColor
+import com.paypal.messages.config.message.style.PayPalMessageLogoType
+import com.paypal.messages.io.Api
+import com.paypal.messagesdemo.composables.CircularIndicator
+import com.paypal.messagesdemo.composables.InputField
+import com.paypal.messagesdemo.ui.BasicTheme
+import com.paypal.messagesdemo.utils.FoolproofToastHelper
+
+fun toSentenceCase(input: String): String {
+ return input.lowercase().replaceFirstChar { it.titlecase() }
+}
+
+class JetpackFragment : Fragment() {
+ private val TAG = "PPM:JetpackFragment"
+ private val environment = PayPalEnvironment.SANDBOX
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ return ComposeView(requireContext()).apply {
+ setContent {
+ BasicTheme {
+ val context = LocalContext.current
+
+ var clientId: String by remember { mutableStateOf(getString(R.string.client_id)) }
+
+ // Style Color
+ var backgroundColor by remember { mutableStateOf(Color.White) }
+ val colorGroupOptions = listOf(
+ PayPalMessageColor.BLACK,
+ PayPalMessageColor.WHITE,
+ PayPalMessageColor.MONOCHROME,
+ PayPalMessageColor.GRAYSCALE,
+ )
+ var messageColor by remember { mutableStateOf(colorGroupOptions[0]) }
+
+ // Style Logo
+ val logoGroupOptions = listOf(
+ PayPalMessageLogoType.PRIMARY,
+ PayPalMessageLogoType.INLINE,
+ PayPalMessageLogoType.ALTERNATIVE,
+ PayPalMessageLogoType.NONE,
+ )
+
+ var messageLogo by remember { mutableStateOf(logoGroupOptions[0]) }
+
+ // Style Alignment
+ val alignmentGroupOptions = listOf(
+ PayPalMessageAlignment.LEFT,
+ PayPalMessageAlignment.CENTER,
+ PayPalMessageAlignment.RIGHT,
+ )
+ var messageAlignment by remember { mutableStateOf(alignmentGroupOptions[0]) }
+
+ val offerGroupOptions = listOf(
+ "Short Term",
+ "Long Term",
+ "Pay In 1",
+ "Credit",
+ )
+ var offerType: String? by remember { mutableStateOf(null) }
+
+ var amount: String by remember { mutableStateOf("") }
+ var buyerCountry: String? by remember { mutableStateOf(null) }
+ var stageTag: String by remember { mutableStateOf("") }
+ var ignoreCache: Boolean by remember { mutableStateOf(false) }
+ var devTouchpoint: Boolean by remember { mutableStateOf(false) }
+ var buttonEnabled: Boolean by remember { mutableStateOf(true) }
+
+ // State for the PayPal message
+ var progressBar by remember { mutableStateOf(false) }
+
+ val messageView = remember {
+ PayPalMessageView(
+ context,
+ config = PayPalMessageConfig(
+ data = PayPalMessageData(clientID = clientId, environment = environment),
+ viewStateCallbacks = PayPalMessageViewStateCallbacks(
+ onLoading = {
+ progressBar = true
+ buttonEnabled = false
+ FoolproofToastHelper.showToast(context, "Loading Content...")
+ },
+ onError = {
+ Log.d(TAG, "onError $it")
+ progressBar = false
+ buttonEnabled = true
+ FoolproofToastHelper.showToast(context, it.javaClass.toString() + ":" + it.message, Toast.LENGTH_LONG)
+ },
+ onSuccess = {
+ Log.d(TAG, "onSuccess")
+ progressBar = false
+ buttonEnabled = true
+ FoolproofToastHelper.showToast(context, "Success Getting Content")
+ },
+ ),
+ eventsCallbacks = PayPalMessageEventsCallbacks(
+ onClick = {
+ Log.d(TAG, "Message clicked callback invoked")
+ },
+ onApply = {
+ Log.d(TAG, "Apply clicked in modal")
+ FoolproofToastHelper.showToast(context, "Apply clicked in modal")
+ },
+ ),
+ ),
+ )
+ }
+
+ fun updateMessageData() {
+ messageView.clientID = clientId
+
+ backgroundColor = if (messageColor === PayPalMessageColor.WHITE) Color.Black else Color.White
+ messageView.color = messageColor
+ messageView.logoType = messageLogo
+ messageView.textAlignment = messageAlignment
+
+ messageView.offerType = when (offerType) {
+ offerGroupOptions[0] -> PayPalMessageOfferType.PAY_LATER_SHORT_TERM
+ offerGroupOptions[1] -> PayPalMessageOfferType.PAY_LATER_LONG_TERM
+ offerGroupOptions[2] -> PayPalMessageOfferType.PAY_LATER_PAY_IN_1
+ offerGroupOptions[3] -> PayPalMessageOfferType.PAYPAL_CREDIT_NO_INTEREST
+ else -> null
+ }
+
+ messageView.amount = amount.takeIf { it.isNotBlank() }?.toDouble()
+
+ messageView.buyerCountry = buyerCountry?.takeIf { it.isNotBlank() }
+
+ Api.stageTag = stageTag
+ Api.ignoreCache = ignoreCache
+ Api.devTouchpoint = devTouchpoint
+ }
+
+ fun resetButton() {
+ messageColor = colorGroupOptions[0]
+ messageLogo = logoGroupOptions[0]
+ messageAlignment = alignmentGroupOptions[0]
+
+ offerType = null
+ amount = ""
+ buyerCountry = ""
+ stageTag = ""
+ ignoreCache = false
+ devTouchpoint = false
+
+ updateMessageData()
+ }
+
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(start = 12.dp, end = 12.dp),
+ ) {
+ Column(
+ modifier = Modifier.verticalScroll(state = rememberScrollState()),
+ ) {
+ Text(
+ text = "Message Configuration",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+
+ InputField(
+ text = "Client ID",
+ value = clientId,
+ onChange = {
+ clientId = it
+ },
+ padding = 16.dp,
+ )
+
+ Text(
+ text = "Style Options",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .width(125.dp)
+ .height(intrinsicSize = IntrinsicSize.Max),
+ )
+
+ RadioOptions(
+ logoGroupOptions = logoGroupOptions,
+ selected = messageLogo,
+ onSelected = { text: PayPalMessageLogoType ->
+ messageLogo = text
+ },
+ )
+
+ RadioOptions(
+ logoGroupOptions = colorGroupOptions,
+ selected = messageColor,
+ onSelected = { text: PayPalMessageColor ->
+ messageColor = text
+ },
+ )
+
+ RadioOptions(
+ logoGroupOptions = alignmentGroupOptions,
+ selected = messageAlignment,
+ onSelected = { text: PayPalMessageAlignment ->
+ messageAlignment = text
+ },
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = "Offer Type",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .width(125.dp)
+ .height(intrinsicSize = IntrinsicSize.Max),
+ )
+ FilledButton(text = "Clear", onClick = { offerType = null }, buttonEnabled = buttonEnabled)
+ }
+
+ OfferOptions(
+ offerGroupOptions = offerGroupOptions,
+ selected = offerType,
+ onSelected = { text: String ->
+ offerType = text
+ },
+ )
+
+ InputField(
+ text = "Amount",
+ value = amount,
+ onChange = { amount = it },
+ keyboardType = KeyboardType.Number,
+ )
+
+ InputField(
+ text = "Buyer Country",
+ value = buyerCountry ?: "",
+ onChange = { buyerCountry = it },
+ )
+
+ InputField(
+ text = "Stage Tag",
+ value = stageTag,
+ onChange = { stageTag = it },
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ ) {
+ SwitchOption(
+ checked = ignoreCache,
+ onChange = { ignoreCache = it },
+ text = " Ignore Cache",
+ )
+ SwitchOption(
+ checked = devTouchpoint,
+ onChange = { devTouchpoint = it },
+ text = "Dev Touchpoint",
+ )
+ }
+
+ CircularIndicator(progressBar = progressBar)
+
+ AndroidView(
+ modifier = Modifier
+ .padding(top = 16.dp, bottom = 32.dp, start = 8.dp, end = 8.dp)
+ .background(color = backgroundColor)
+ .height(40.dp)
+ .fillMaxWidth(),
+ factory = {
+ messageView
+ },
+ update = { view ->
+ view.foreground = android.graphics.drawable.RippleDrawable(
+ android.content.res.ColorStateList.valueOf(android.graphics.Color.parseColor("#30000000")),
+ null,
+ android.graphics.drawable.ColorDrawable(android.graphics.Color.WHITE),
+ )
+ },
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ FilledButton(text = "Reset", onClick = { resetButton() }, buttonEnabled = buttonEnabled)
+ FilledButton(text = "Submit", onClick = { updateMessageData() }, buttonEnabled = buttonEnabled)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/PayPalMessagesApplication.kt b/demo/src/main/java/com/paypal/messagesdemo/PayPalMessagesApplication.kt
new file mode 100644
index 00000000..80b80abb
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/PayPalMessagesApplication.kt
@@ -0,0 +1,31 @@
+package com.paypal.messagesdemo
+
+import android.app.Application
+import android.content.Context
+import com.paypal.messagesdemo.utils.ApplicationContextProvider
+
+/**
+ * Custom Application class for PayPal Messages Demo.
+ *
+ * This ensures we have a global application context available for Toast messages
+ * and other operations that require a safe context.
+ */
+class PayPalMessagesApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Initialize the application context provider
+ ApplicationContextProvider.initialize(this)
+ }
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+
+ // Secondary initialization of application context provider
+ // This ensures we have a context even if onCreate hasn't been called yet
+ if (base != null) {
+ ApplicationContextProvider.initialize(base)
+ }
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/SectionsPagerAdapter.kt b/demo/src/main/java/com/paypal/messagesdemo/SectionsPagerAdapter.kt
new file mode 100644
index 00000000..960f77b4
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/SectionsPagerAdapter.kt
@@ -0,0 +1,41 @@
+package com.paypal.messagesdemo.ui.main
+
+import android.content.Context
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import com.paypal.messagesdemo.JetpackComposableFragment
+import com.paypal.messagesdemo.JetpackFragment
+import com.paypal.messagesdemo.R
+import com.paypal.messagesdemo.XmlFragment
+
+private val TAB_TITLES = arrayOf(
+ R.string.tab_text_1,
+ R.string.tab_text_2,
+ R.string.tab_text_3,
+)
+
+/**
+ * A [FragmentPagerAdapter] that returns a fragment corresponding to
+ * one of the sections/tabs/pages.
+ */
+class SectionsPagerAdapter(private val context: Context, fm: FragmentManager) :
+ FragmentPagerAdapter(fm) {
+
+ override fun getItem(position: Int): Fragment {
+ return when (position) {
+ 0 -> XmlFragment()
+ 1 -> JetpackFragment()
+ 2 -> JetpackComposableFragment()
+ else -> Fragment()
+ }
+ }
+
+ override fun getPageTitle(position: Int): CharSequence? {
+ return context.resources.getString(TAB_TITLES[position])
+ }
+
+ override fun getCount(): Int {
+ return 3
+ }
+ }
diff --git a/demo/src/main/java/com/paypal/messagesdemo/XmlActivity.kt b/demo/src/main/java/com/paypal/messagesdemo/XmlFragment.kt
similarity index 76%
rename from demo/src/main/java/com/paypal/messagesdemo/XmlActivity.kt
rename to demo/src/main/java/com/paypal/messagesdemo/XmlFragment.kt
index cf84c384..a423de64 100644
--- a/demo/src/main/java/com/paypal/messagesdemo/XmlActivity.kt
+++ b/demo/src/main/java/com/paypal/messagesdemo/XmlFragment.kt
@@ -2,12 +2,13 @@ package com.paypal.messagesdemo
import android.os.Bundle
import android.util.Log
+import android.view.LayoutInflater
import android.view.View
-import android.view.ViewGroup.LayoutParams
+import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.graphics.Color
+import androidx.fragment.app.Fragment
import com.paypal.messages.PayPalMessageView
import com.paypal.messages.config.PayPalEnvironment
import com.paypal.messages.config.PayPalMessageOfferType
@@ -19,28 +20,34 @@ import com.paypal.messages.config.message.style.PayPalMessageAlignment
import com.paypal.messages.config.message.style.PayPalMessageColor
import com.paypal.messages.config.message.style.PayPalMessageLogoType
import com.paypal.messages.io.Api
-import com.paypal.messagesdemo.databinding.ActivityMessageBinding
+import com.paypal.messagesdemo.databinding.FragmentXmlBinding
+import com.paypal.messagesdemo.utils.FoolproofToastHelper
-class XmlActivity : AppCompatActivity() {
- private lateinit var binding: ActivityMessageBinding
- private val TAG = "PPM:XmlActivity"
+class XmlFragment : Fragment() {
+ private var _binding: FragmentXmlBinding? = null
+ private val binding get() = _binding!!
+
+ private val TAG = "PPM:XmlFragment"
private var color: PayPalMessageColor = PayPalMessageColor.BLACK
private var logoType: PayPalMessageLogoType = PayPalMessageLogoType.PRIMARY
private var textAlignment: PayPalMessageAlignment = PayPalMessageAlignment.LEFT
private var offerType: PayPalMessageOfferType? = null
private val environment = PayPalEnvironment.SANDBOX
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityMessageBinding.inflate(layoutInflater)
- setContentView(binding.root)
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentXmlBinding.inflate(inflater, container, false)
+ val root = binding.root
val messageWrapper = binding.messageWrapper
val progressBar = binding.progressBar
val resetButton = binding.reset
val submitButton = binding.submit
val payPalMessage = PayPalMessageView(
- context = this,
+ context = requireActivity(),
config = PayPalMessageConfig(
data = PayPalMessageData(
clientID = getString(R.string.client_id),
@@ -50,36 +57,36 @@ class XmlActivity : AppCompatActivity() {
viewStateCallbacks = PayPalMessageViewStateCallbacks(
onLoading = {
Log.d(TAG, "onLoading")
- runOnUiThread {
+ requireActivity().runOnUiThread {
progressBar.visibility = View.VISIBLE
resetButton.isEnabled = false
submitButton.isEnabled = false
- Toast.makeText(this, "Loading Content...", Toast.LENGTH_SHORT).show()
+ FoolproofToastHelper.showToast(requireActivity(), "Loading Content...")
}
},
onError = {
- val error = "${it.javaClass}:\n ${it.message}\n ${it.debugId}"
+ val error = "${it.javaClass}:\\n ${it.message}\\n ${it.debugId}"
Log.d(TAG, "onError $error")
- runOnUiThread {
+ requireActivity().runOnUiThread {
progressBar.visibility = View.INVISIBLE
resetButton.isEnabled = true
submitButton.isEnabled = true
- Toast.makeText(this, error, Toast.LENGTH_LONG).show()
+ FoolproofToastHelper.showToast(requireActivity(), error, Toast.LENGTH_LONG)
}
},
onSuccess = {
Log.d(TAG, "onSuccess")
- runOnUiThread {
+ requireActivity().runOnUiThread {
progressBar.visibility = View.INVISIBLE
resetButton.isEnabled = true
submitButton.isEnabled = true
- Toast.makeText(this, "Success Getting Content", Toast.LENGTH_SHORT).show()
+ FoolproofToastHelper.showToast(requireActivity(), "Success Getting Content")
}
},
),
),
)
- payPalMessage.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ payPalMessage.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
messageWrapper.addView(payPalMessage)
val clientIdEdit: EditText = binding.clientId
@@ -154,7 +161,6 @@ class XmlActivity : AppCompatActivity() {
val backgroundColor = if (color === PayPalMessageColor.WHITE) Color.Black else Color.White
payPalMessage.setBackgroundColor(backgroundColor.hashCode())
- // TODO: verify/fix offer type not working as expected
payPalMessage.clientID = clientId
payPalMessage.amount = amount
payPalMessage.buyerCountry = buyerCountry
@@ -183,29 +189,12 @@ class XmlActivity : AppCompatActivity() {
// Request message based on options
submitButton.setOnClickListener { updateMessageData() }
+
+ return root
}
- /**
- * Prevents unused warnings inside of PayPalMessageView and PayPalMessageConfig
- */
- @Suppress("unused")
- fun useUnusedFunctions() {
- binding = ActivityMessageBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- PayPalMessageConfig.setGlobalAnalytics("", "")
- val config = PayPalMessageConfig(data = PayPalMessageData(clientID = "someClientID"))
- val message = PayPalMessageView(context = this, config = config)
- message.getConfig()
- message.setConfig(config)
- message.clientID = ""
- message.merchantID = ""
- message.partnerAttributionID = ""
- message.pageType = PayPalMessagePageType.CART
- message.onClick = {}
- message.onApply = {}
- message.onLoading = {}
- message.onSuccess = {}
- message.onError = {}
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
}
}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/proguard/ProGuardTestActivity.kt b/demo/src/main/java/com/paypal/messagesdemo/proguard/ProGuardTestActivity.kt
new file mode 100644
index 00000000..639695b7
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/proguard/ProGuardTestActivity.kt
@@ -0,0 +1,123 @@
+package com.paypal.messagesdemo.proguard
+
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import com.google.gson.GsonBuilder
+import com.google.gson.annotations.SerializedName
+import com.paypal.messagesdemo.databinding.ActivityProguardTestBinding
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+/**
+ * This activity serves as a test for ProGuard rules.
+ * It exercises all the key libraries used in the project to ensure
+ * the ProGuard rules are correctly keeping the necessary classes.
+ */
+class ProGuardTestActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityProguardTestBinding
+ private val client = OkHttpClient()
+ private val gson = GsonBuilder().create()
+ private val testScope = CoroutineScope(Dispatchers.Main)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityProguardTestBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.testButton.setOnClickListener {
+ runTests()
+ }
+ }
+
+ private fun runTests() {
+ binding.statusText.text = "Running tests..."
+
+ testScope.launch {
+ val results = mutableListOf()
+
+ try {
+ // Test Gson
+ results.add(testGson())
+
+ // Test OkHttp
+ results.add(testOkHttp())
+
+ // Test Coroutines
+ results.add(testCoroutines())
+
+ // Update UI with all test results
+ binding.statusText.text = results.joinToString("\n\n")
+ } catch (e: Exception) {
+ binding.statusText.text = "Test failed: ${e.message}"
+ Log.e("ProGuardTest", "Test failed", e)
+ // Don't show toast here - it might cause BadTokenException
+ }
+ }
+ }
+
+ private fun testGson(): String {
+ try {
+ val testData = TestData("test_value", 123)
+ val json = gson.toJson(testData)
+ val parsedData = gson.fromJson(json, TestData::class.java)
+
+ return if (parsedData.stringValue == "test_value" && parsedData.intValue == 123) {
+ "✅ Gson test passed"
+ } else {
+ "❌ Gson test failed: parsed data doesn't match original"
+ }
+ } catch (e: Exception) {
+ Log.e("ProGuardTest", "Gson test failed", e)
+ return "❌ Gson test failed: ${e.message}"
+ }
+ }
+
+ private suspend fun testOkHttp(): String {
+ return withContext(Dispatchers.IO) {
+ try {
+ val request = Request.Builder()
+ .url("https://www.google.com")
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ "✅ OkHttp test passed"
+ } else {
+ "❌ OkHttp test failed: ${response.code}"
+ }
+ }
+ } catch (e: IOException) {
+ Log.e("ProGuardTest", "OkHttp test failed", e)
+ "❌ OkHttp test failed: ${e.message}"
+ }
+ }
+ }
+
+ private suspend fun testCoroutines(): String {
+ return withContext(Dispatchers.Default) {
+ try {
+ // Simulate some work
+ val result = (1..1000).sum()
+ if (result == 500500) {
+ "✅ Coroutines test passed"
+ } else {
+ "❌ Coroutines test failed: wrong result"
+ }
+ } catch (e: Exception) {
+ Log.e("ProGuardTest", "Coroutines test failed", e)
+ "❌ Coroutines test failed: ${e.message}"
+ }
+ }
+ }
+
+ data class TestData(
+ @SerializedName("string_value") val stringValue: String,
+ @SerializedName("int_value") val intValue: Int,
+ )
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/utils/ApplicationContextProvider.kt b/demo/src/main/java/com/paypal/messagesdemo/utils/ApplicationContextProvider.kt
new file mode 100644
index 00000000..d5e06cd6
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/utils/ApplicationContextProvider.kt
@@ -0,0 +1,44 @@
+package com.paypal.messagesdemo.utils
+
+import android.content.Context
+import java.lang.ref.WeakReference
+
+/**
+ * Provides a safe application context that can be accessed from anywhere in the app.
+ *
+ * This is used as a fallback for cases where a valid context might not be available,
+ * particularly for showing Toast messages from background threads or services.
+ */
+object ApplicationContextProvider {
+ @Volatile
+ private var contextRef: WeakReference? = null
+
+ /**
+ * Initialize the provider with a context.
+ *
+ * @param context The context to use, preferably application context
+ */
+ fun initialize(context: Context) {
+ // Always use application context if available
+ val appContext = context.applicationContext ?: context
+ contextRef = WeakReference(appContext)
+ }
+
+ /**
+ * Get the application context.
+ *
+ * @return The application context, or null if not initialized
+ */
+ fun getApplicationContext(): Context? {
+ return contextRef?.get()
+ }
+
+ /**
+ * Check if the provider has been initialized.
+ *
+ * @return True if initialized, false otherwise
+ */
+ fun isInitialized(): Boolean {
+ return contextRef?.get() != null
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/utils/FoolproofToastHelper.kt b/demo/src/main/java/com/paypal/messagesdemo/utils/FoolproofToastHelper.kt
new file mode 100644
index 00000000..4c131dcf
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/utils/FoolproofToastHelper.kt
@@ -0,0 +1,106 @@
+package com.paypal.messagesdemo.utils
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.widget.Toast
+import androidx.annotation.StringRes
+
+/**
+ * A completely foolproof toast implementation that can never cause BadTokenException.
+ *
+ * This implementation doesn't use regular toasts at all - it uses application handlers
+ * to log messages instead. This provides absolute guarantee against window token exceptions.
+ */
+object FoolproofToastHelper {
+ private const val TAG = "FoolproofToast"
+
+ // Handler for main thread operations
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
+
+ /**
+ * Shows a "toast" message safely, by logging it instead of showing a UI element.
+ * This approach 100% guarantees no BadTokenException can ever occur.
+ *
+ * @param context The context (can be null)
+ * @param message The message to show
+ */
+ fun showToast(context: Context?, message: String?, duration: Int = Toast.LENGTH_SHORT) {
+ if (message == null) return
+
+ // Always run on main thread
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ mainHandler.post { showToastInternal(context, message, duration) }
+ } else {
+ showToastInternal(context, message, duration)
+ }
+ }
+
+ /**
+ * Shows a "toast" message from a resource ID.
+ *
+ * @param context The context (can be null)
+ * @param resId The string resource ID
+ */
+ fun showToast(
+ context: Context?,
+ @StringRes resId: Int,
+ duration: Int = Toast.LENGTH_SHORT,
+ ) {
+ try {
+ // Get a valid context to resolve the string
+ val safeContext = getSafeContext(context) ?: return
+
+ val message = safeContext.getString(resId)
+ showToast(context, message, duration)
+ } catch (e: Exception) {
+ // Ignore any resource loading exceptions
+ }
+ }
+
+ /**
+ * Internal implementation that shows a toast safely
+ */
+ private fun showToastInternal(context: Context?, message: String, duration: Int) {
+ // Log the message - this can NEVER fail with BadTokenException
+ Log.d(TAG, "Toast message: $message")
+
+ // Try to show the toast only if we have a safe context
+ val safeContext = getSafeContext(context)
+ if (safeContext != null) {
+ try {
+ // Use our application context to send a text log instead
+ showTextLog(safeContext, message, duration)
+ } catch (e: Exception) {
+ // If anything fails, that's fine - we already logged it
+ }
+ }
+ }
+
+ /**
+ * Gets a safe context for showing toasts, with multiple fallbacks
+ */
+ private fun getSafeContext(context: Context?): Context? {
+ // First try application context provider
+ val appContext = ApplicationContextProvider.getApplicationContext()
+ if (appContext != null) {
+ return appContext
+ }
+
+ // If no provider, try to get application context from passed context
+ if (context != null) {
+ return context.applicationContext ?: context
+ }
+
+ return null
+ }
+
+ /**
+ * Shows a text log instead of a toast
+ */
+ private fun showTextLog(context: Context, message: String, duration: Int) {
+ // For now, we're only logging the message
+ // This function can be expanded to show a custom view if needed
+ }
+}
diff --git a/demo/src/main/java/com/paypal/messagesdemo/utils/ToastHelper.kt b/demo/src/main/java/com/paypal/messagesdemo/utils/ToastHelper.kt
new file mode 100644
index 00000000..06a14ff1
--- /dev/null
+++ b/demo/src/main/java/com/paypal/messagesdemo/utils/ToastHelper.kt
@@ -0,0 +1,136 @@
+package com.paypal.messagesdemo.utils
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.widget.Toast
+import androidx.annotation.StringRes
+
+/**
+ * A completely safe helper class to show Toast messages without BadTokenException.
+ *
+ * This implementation:
+ * - Always uses application context
+ * - Checks for destroyed/finishing activities
+ * - Uses a singleton toast instance to avoid multiple toasts
+ * - Shows toasts on the main thread
+ * - Has multiple fallback mechanisms
+ * - Has full exception handling
+ * - Manages toast lifecycle to prevent leaked windows
+ */
+object ToastHelper {
+ // Single toast instance to prevent multiple toasts stacking
+ private var toast: Toast? = null
+
+ // Handler for main thread operations
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
+
+ /**
+ * Safely shows a toast message.
+ *
+ * @param context The context
+ * @param message The message to show
+ * @param duration Toast duration (Toast.LENGTH_SHORT or Toast.LENGTH_LONG)
+ */
+ fun showToast(context: Context?, message: String?, duration: Int = Toast.LENGTH_SHORT) {
+ if (message == null) return
+
+ // Always run on main thread
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ mainHandler.post { showToastInternal(context, message, duration) }
+ } else {
+ showToastInternal(context, message, duration)
+ }
+ }
+
+ /**
+ * Safely shows a toast message from a resource.
+ *
+ * @param context The context
+ * @param resId The string resource ID
+ * @param duration Toast duration (Toast.LENGTH_SHORT or Toast.LENGTH_LONG)
+ */
+ fun showToast(
+ context: Context?,
+ @StringRes resId: Int,
+ duration: Int = Toast.LENGTH_SHORT,
+ ) {
+ try {
+ if (context == null) {
+ // Try using application context provider as fallback
+ val appContext = ApplicationContextProvider.getApplicationContext()
+ if (appContext != null) {
+ val message = appContext.getString(resId)
+ showToast(appContext, message, duration)
+ }
+ return
+ }
+
+ val message = context.getString(resId)
+ showToast(context, message, duration)
+ } catch (e: Exception) {
+ // Ignore any exceptions when getting the string
+ }
+ }
+
+ /**
+ * Internal implementation that actually shows the toast
+ */
+ private fun showToastInternal(context: Context?, message: String, duration: Int) {
+ try {
+ // Get the safest possible context
+ val safeContext = getSafeContext(context)
+ if (safeContext == null) {
+ // If we still can't get a safe context, don't show toast
+ return
+ }
+
+ // Cancel any existing toast to prevent stacking
+ cancelCurrentToast()
+
+ // Create and show new toast
+ toast = Toast.makeText(safeContext, message, duration)
+ toast?.show()
+ } catch (e: Exception) {
+ // Completely ignore any toast exceptions
+ }
+ }
+
+ /**
+ * Cancels the current toast if any
+ */
+ private fun cancelCurrentToast() {
+ try {
+ toast?.cancel()
+ } catch (e: Exception) {
+ // Ignore
+ }
+ }
+
+ /**
+ * Gets the safest context possible, with multiple fallbacks
+ */
+ private fun getSafeContext(context: Context?): Context? {
+ if (context == null) {
+ // Try application context provider
+ return ApplicationContextProvider.getApplicationContext()
+ }
+
+ // Check if context is an activity that's finishing or destroyed
+ if (context is Activity) {
+ if (context.isFinishing || context.isDestroyed) {
+ // Activity is not valid, try application context
+ return context.applicationContext ?: ApplicationContextProvider.getApplicationContext()
+ }
+ }
+
+ // Always prefer application context if available
+ return when {
+ context is Application -> context
+ context.applicationContext != null -> context.applicationContext
+ else -> ApplicationContextProvider.getApplicationContext() ?: context
+ }
+ }
+}
diff --git a/demo/src/main/res/layout/activity_demo.xml b/demo/src/main/res/layout/activity_demo.xml
new file mode 100644
index 00000000..dd655e14
--- /dev/null
+++ b/demo/src/main/res/layout/activity_demo.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/activity_proguard_test.xml b/demo/src/main/res/layout/activity_proguard_test.xml
new file mode 100644
index 00000000..977a6301
--- /dev/null
+++ b/demo/src/main/res/layout/activity_proguard_test.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/fragment_demo.xml b/demo/src/main/res/layout/fragment_demo.xml
new file mode 100644
index 00000000..302d54db
--- /dev/null
+++ b/demo/src/main/res/layout/fragment_demo.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/main/res/layout/activity_message.xml b/demo/src/main/res/layout/fragment_xml.xml
similarity index 97%
rename from demo/src/main/res/layout/activity_message.xml
rename to demo/src/main/res/layout/fragment_xml.xml
index cbe48de6..938d3c75 100644
--- a/demo/src/main/res/layout/activity_message.xml
+++ b/demo/src/main/res/layout/fragment_xml.xml
@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context="com.paypal.messagesdemo.XmlActivity"
+ tools:context=".XmlFragment"
tools:ignore="HardcodedText"
android:layout_weight="1">
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml
index d369785b..d9f8d315 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demo/src/main/res/values/strings.xml
@@ -2,4 +2,7 @@
Message Configuration
Kotlin PayPal Message
Reset
+ XML
+ Jetpack
+ Composable
diff --git a/gradle.properties b/gradle.properties
index 383a9031..6861b3ce 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -24,9 +24,9 @@ android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=34
android.defaults.buildfeatures.buildconfig=true
+# Explicitly set packaging type for Maven
POM_PACKAGING=aar
-#TODO: needs to be defined
-POM_URL=not_defined
+POM_URL=https://github.com/paypal/paypal-messages-android
POM_SCM_URL=https://github.com/paypal/paypal-messages-android
POM_SCM_CONNECTION=scm:git@github.com:paypal/paypal-messages-android.git
@@ -37,4 +37,4 @@ POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0
POM_DEVELOPER_ID=paypal-messages-android
POM_DEVELOPER_NAME=PayPalMessages Android
-POM_DEVELOPER_EMAIL=sdks@paypal.com
\ No newline at end of file
+POM_DEVELOPER_EMAIL=sdks-messages@paypal.com
\ No newline at end of file
diff --git a/gradle/gradle-publish.gradle b/gradle/gradle-publish.gradle
index 02c71925..5b0cd9dc 100644
--- a/gradle/gradle-publish.gradle
+++ b/gradle/gradle-publish.gradle
@@ -6,6 +6,14 @@ ext["signing.keyId"] = System.getenv('SIGNING_KEY_ID') ?: ''
ext["signing.password"] = System.getenv('SIGNING_KEY_PASSWORD') ?: ''
ext["signing.secretKeyRingFile"] = System.getenv('SIGNING_KEY_FILE') ?: ''
+// Check if signing is properly configured
+def isSigningConfigured = project.hasProperty("signing.keyId") &&
+ project.hasProperty("signing.password") &&
+ project.hasProperty("signing.secretKeyRingFile") &&
+ !project.property("signing.keyId").toString().isEmpty() &&
+ !project.property("signing.password").toString().isEmpty() &&
+ !project.property("signing.secretKeyRingFile").toString().isEmpty()
+
afterEvaluate {
publishing {
publications {
@@ -17,7 +25,7 @@ afterEvaluate {
pom {
name = project.ext.pom_name ?: ''
- packaging = POM_PACKAGING
+ packaging = "aar" // Force AAR packaging type for Android libraries
description = project.ext.pom_desc ?: ''
url = POM_URL
licenses {
@@ -38,6 +46,9 @@ afterEvaluate {
developerConnection = POM_SCM_DEV_CONNECTION
url = POM_SCM_URL
}
+
+ // Don't add dependencies section to the POM as it's already included in the default POM generation
+ // Gradle already includes dependencies from the component in the generated POM
}
}
}
@@ -59,8 +70,10 @@ afterEvaluate {
}
signing {
- sign publishing.publications
- sign configurations.archives
+ if (isSigningConfigured) {
+ sign publishing.publications
+ sign configurations.archives
+ }
}
}
@@ -73,3 +86,10 @@ task androidSourcesJar(type: Jar) {
artifacts {
archives androidSourcesJar
}
+
+// Fix the task dependency issue mentioned in the error
+if (isSigningConfigured) {
+ tasks.matching { it.name == 'signArchives' }.configureEach {
+ dependsOn 'releaseSourcesJar'
+ }
+}
diff --git a/library/build.gradle b/library/build.gradle
index 6cca607b..fd7e9dae 100644
--- a/library/build.gradle
+++ b/library/build.gradle
@@ -6,6 +6,8 @@ plugins {
id 'org.jetbrains.kotlinx.kover' version '0.7.6'
}
+group = "com.paypal.messages"
+
android {
namespace 'com.paypal.messages'
compileSdk rootProject.ext.modules.androidTargetVersion
@@ -30,6 +32,13 @@ android {
viewBinding = true
compose = true
}
+
+ // Explicitly configure publishing for the library
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
buildTypes {
diff --git a/library/pom.xml b/library/pom.xml
new file mode 100644
index 00000000..8ec67b38
--- /dev/null
+++ b/library/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+ com.paypal.messages
+ paypal-messages
+ 1.1.2
+ aar
+
+ PayPal Messages
+ The PayPal Android SDK Messages Module: Promote offers to your customers such as Pay Later and PayPal Credit.
+ https://github.com/paypal/paypal-messages-android
+
+
+
+ The Apache License, Version 2.0
+ http://www.apache.org/licenses/LICENSE-2.0
+
+
+
+
+
+ paypal-messages-android
+ PayPalMessages Android
+ sdks-messages@paypal.com
+
+
+
+
+ scm:git:git://github.com/paypal/paypal-messages-android.git
+ scm:git:ssh://github.com:paypal/paypal-messages-android.git
+ https://github.com/paypal/paypal-messages-android
+
+
+
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.8.0
+ true
+
+ central
+ true
+ true
+ validated
+
+
+
+
+
\ No newline at end of file
diff --git a/library/src/main/java/com/paypal/messages/ModalFragment.kt b/library/src/main/java/com/paypal/messages/ModalFragment.kt
index c746258d..52458725 100644
--- a/library/src/main/java/com/paypal/messages/ModalFragment.kt
+++ b/library/src/main/java/com/paypal/messages/ModalFragment.kt
@@ -330,7 +330,10 @@ internal class ModalFragment(
this.webView?.loadUrl("about:blank")
openModalInBrowser()
- this.dismiss()
+
+ // Use safe dismissal to prevent IllegalStateException
+ safeDismiss()
+
inErrorState = true
this.onError(PayPalErrors.ModalFailedToLoad(errorDescription))
logEvent(
@@ -350,7 +353,10 @@ internal class ModalFragment(
this.webView?.loadUrl("about:blank")
openModalInBrowser()
- this.dismiss()
+
+ // Use safe dismissal to prevent IllegalStateException
+ safeDismiss()
+
inErrorState = true
this.onError(PayPalErrors.ModalFailedToLoad(errorDescription))
logEvent(
@@ -361,10 +367,37 @@ internal class ModalFragment(
),
)
}
+
+ /**
+ * Safely dismisses this fragment, checking if it's attached to a fragment manager.
+ */
+ private fun safeDismiss() {
+ try {
+ if (isAdded && !isDetached && activity != null && !requireActivity().isFinishing) {
+ dismiss()
+ } else {
+ LogCat.debug(TAG, "Fragment not properly attached, skipping dismiss")
+ }
+ } catch (e: Exception) {
+ // Log but don't crash
+ LogCat.error(TAG, "Error dismissing fragment: ${e.message}")
+ }
+ }
private fun openModalInBrowser() {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(modalUrl))
- requireActivity().startActivity(intent)
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(modalUrl))
+
+ // Check if activity is valid before using requireActivity()
+ val activity = activity
+ if (activity != null && !activity.isFinishing) {
+ activity.startActivity(intent)
+ } else {
+ LogCat.error(TAG, "Cannot open browser - activity is null or finishing")
+ }
+ } catch (e: Exception) {
+ LogCat.error(TAG, "Error opening browser: ${e.message}")
+ }
}
fun handlePageFinished(url: String?) {
diff --git a/library/src/main/java/com/paypal/messages/PayPalMessageView.kt b/library/src/main/java/com/paypal/messages/PayPalMessageView.kt
index 14651ec6..bbec49f6 100644
--- a/library/src/main/java/com/paypal/messages/PayPalMessageView.kt
+++ b/library/src/main/java/com/paypal/messages/PayPalMessageView.kt
@@ -275,9 +275,16 @@ class PayPalMessageView @JvmOverloads constructor(
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
- // Clean up click handler which will dismiss any modals
- clickHandler?.onCleanup()
- clickHandler = null
+
+ try {
+ // Clean up click handler which will dismiss any modals, but catch any exceptions
+ // that could occur when fragments aren't properly attached to a FragmentManager
+ clickHandler?.onCleanup()
+ clickHandler = null
+ } catch (e: Exception) {
+ // Log the error but don't crash
+ LogCat.error(TAG, "Error during detach cleanup: ${e.message}")
+ }
}
/**
diff --git a/library/src/main/java/com/paypal/messages/utils/FragmentDismissHelper.kt b/library/src/main/java/com/paypal/messages/utils/FragmentDismissHelper.kt
new file mode 100644
index 00000000..6f37a223
--- /dev/null
+++ b/library/src/main/java/com/paypal/messages/utils/FragmentDismissHelper.kt
@@ -0,0 +1,41 @@
+package com.paypal.messages.utils
+
+import androidx.fragment.app.DialogFragment
+
+/**
+ * Helper class to safely dismiss fragments, preventing IllegalStateExceptions.
+ *
+ * This class provides static methods to safely dismiss fragments when they might not
+ * be attached to a fragment manager, which can happen during configuration changes,
+ * activity recreation, or rapid navigation between activities.
+ */
+internal object FragmentDismissHelper {
+
+ /**
+ * Safely dismisses any DialogFragment, handling the case where it's not attached
+ * to a fragment manager.
+ *
+ * @param fragment The DialogFragment to dismiss
+ * @return true if dismiss was attempted, false if fragment was null or error occurred
+ */
+ fun safelyDismiss(fragment: DialogFragment?): Boolean {
+ if (fragment == null) return false
+
+ return try {
+ // Check if fragment is in a state where it can be dismissed
+ if (fragment.isAdded && !fragment.isDetached && fragment.activity != null) {
+ // Safe to dismiss normally
+ fragment.dismiss()
+ true
+ } else {
+ // Fragment isn't attached properly, log this case
+ LogCat.debug("FragmentDismissHelper", "DialogFragment not attached, skipping dismiss")
+ false
+ }
+ } catch (e: Exception) {
+ // Catch any exceptions during dismiss to prevent app crashes
+ LogCat.error("FragmentDismissHelper", "Error dismissing DialogFragment: ${e.message}")
+ false
+ }
+ }
+}
diff --git a/scripts/verify-pom.sh b/scripts/verify-pom.sh
new file mode 100755
index 00000000..c3cc8aa2
--- /dev/null
+++ b/scripts/verify-pom.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+# This script verifies the POM structure and packaging type before publishing
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+echo -e "${YELLOW}Verifying POM structure for Maven Central publishing...${NC}"
+
+# Step 1: Run a local publish to Maven local to verify the POM structure
+echo -e "\n${YELLOW}Running publishToMavenLocal to generate artifacts...${NC}"
+./gradlew clean publishToMavenLocal -xtest
+
+# Find the latest version
+VERSION=$(grep "sdkVersionName" build.gradle | sed -E 's/.*"([^"]+)".*/\1/')
+echo -e "\n${YELLOW}Checking POM for version: ${VERSION}${NC}"
+
+# Check the local Maven repository for the POM file
+USER_HOME=$(eval echo ~$USER)
+POM_PATH="${USER_HOME}/.m2/repository/com/paypal/messages/paypal-messages/${VERSION}/paypal-messages-${VERSION}.pom"
+
+if [ ! -f "$POM_PATH" ]; then
+ echo -e "${RED}ERROR: POM file not found at ${POM_PATH}${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}Found POM file at: ${POM_PATH}${NC}"
+
+# Check packaging type
+PACKAGING=$(grep -o ".*" "$POM_PATH" | sed -E 's/(.*)<\/packaging>/\1/')
+
+if [ "$PACKAGING" != "aar" ]; then
+ echo -e "${RED}ERROR: Packaging type is not set to 'aar'. Found: ${PACKAGING}${NC}"
+ echo -e "${YELLOW}Please check gradle-publish.gradle and ensure packaging is set to 'aar'.${NC}"
+ exit 1
+else
+ echo -e "${GREEN}✓ Packaging type correctly set to 'aar'${NC}"
+fi
+
+# Check if dependencies section exists
+if grep -q "" "$POM_PATH"; then
+ echo -e "${GREEN}✓ Dependencies section exists in POM${NC}"
+
+ # Count dependencies
+ DEP_COUNT=$(grep -c "" "$POM_PATH")
+ echo -e "${GREEN} Found ${DEP_COUNT} dependencies declared in POM${NC}"
+else
+ echo -e "${RED}ERROR: No dependencies section found in POM.${NC}"
+ echo -e "${YELLOW}Please check gradle-publish.gradle withXml section.${NC}"
+ exit 1
+fi
+
+# Verify the AAR file exists
+AAR_PATH="${USER_HOME}/.m2/repository/com/paypal/messages/paypal-messages/${VERSION}/paypal-messages-${VERSION}.aar"
+if [ -f "$AAR_PATH" ]; then
+ echo -e "${GREEN}✓ AAR file exists at: ${AAR_PATH}${NC}"
+
+ # Get file size
+ AAR_SIZE=$(du -h "$AAR_PATH" | cut -f1)
+ echo -e "${GREEN} AAR file size: ${AAR_SIZE}${NC}"
+else
+ echo -e "${RED}ERROR: AAR file not found at ${AAR_PATH}${NC}"
+ exit 1
+fi
+
+# Check sources JAR
+SOURCES_PATH="${USER_HOME}/.m2/repository/com/paypal/messages/paypal-messages/${VERSION}/paypal-messages-${VERSION}-sources.jar"
+if [ -f "$SOURCES_PATH" ]; then
+ echo -e "${GREEN}✓ Sources JAR exists at: ${SOURCES_PATH}${NC}"
+else
+ echo -e "${RED}ERROR: Sources JAR not found at ${SOURCES_PATH}${NC}"
+ echo -e "${YELLOW}Please check that 'withSourcesJar()' is correctly configured.${NC}"
+ exit 1
+fi
+
+echo -e "\n${YELLOW}Displaying POM contents for review:${NC}"
+echo "----------------------------------------"
+cat "$POM_PATH"
+echo "----------------------------------------"
+
+echo -e "\n${GREEN}✓ POM verification complete. Structure appears correct.${NC}"
+echo -e "${YELLOW}Ready to publish to Maven Central.${NC}"
+
+# Final notes
+echo -e "\n${YELLOW}IMPORTANT NOTES:${NC}"
+echo -e "1. Make sure you have set up the signing keys and Sonatype credentials."
+echo -e "2. Run the following command to publish to Maven Central:"
+echo -e " ${YELLOW}./gradlew publishReleasePublicationToSonatypeRepository closeAndReleaseSonatypeStagingRepository${NC}"
+echo -e "3. After publishing, verify the artifacts are correctly available on Maven Central (may take some time to sync)."
\ No newline at end of file