diff --git a/CHANGELOG.md b/CHANGELOG.md index 850fc1b1..30b3af40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## [2.5.6] - Store SDK - 2024-05-06 +### Changed +- `getVirtualItems` SDK method. Added the `requestGeoLocale` parameter. If `true`, the response returns the locale in the `geoLocale` parameter. + +## [1.3.4] - Payments SDK - 2024-05-06 +### Added +- Pay Station preloader. Allows faster content display in WebView and Custom Tabs. + ## [2.2.11] - Demo Apps - 2024-04-11 ### Fixed - Token refresh on application startup diff --git a/build.gradle b/build.gradle index 3f3f3d23..8a37f74c 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,8 @@ buildscript { - ext.payments_sdk_version_name = '1.3.3' - ext.store_sdk_version_name = '2.5.5' + ext.payments_sdk_version_name = '1.3.4' + ext.store_sdk_version_name = '2.5.6' ext.inventory_sdk_version_name = '2.0.4' ext.login_sdk_version_name = '6.0.7' diff --git a/xsolla-payments-sdk/build.gradle b/xsolla-payments-sdk/build.gradle index dee6824e..cb6590f0 100644 --- a/xsolla-payments-sdk/build.gradle +++ b/xsolla-payments-sdk/build.gradle @@ -41,6 +41,8 @@ dependencies { implementation "androidx.core:core-ktx:$androidx_core_ktx" implementation "androidx.browser:browser:$androidx_browser" + + implementation "androidx.startup:startup-runtime:1.1.1" } dokkaHtmlPartial { diff --git a/xsolla-payments-sdk/src/main/AndroidManifest.xml b/xsolla-payments-sdk/src/main/AndroidManifest.xml index cb8476cc..3c5b583a 100644 --- a/xsolla-payments-sdk/src/main/AndroidManifest.xml +++ b/xsolla-payments-sdk/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -30,6 +31,14 @@ android:scheme="app" /> + + + \ No newline at end of file diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/XPayments.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/XPayments.kt index bc24b29a..ab59b05d 100644 --- a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/XPayments.kt +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/XPayments.kt @@ -5,8 +5,8 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Parcelable -import android.util.Log import androidx.core.os.bundleOf +import com.xsolla.android.payments.caching.PayStationCache import com.xsolla.android.payments.data.AccessToken import com.xsolla.android.payments.ui.ActivityPayStation import com.xsolla.android.payments.util.AnalyticsUtils @@ -81,8 +81,7 @@ class XPayments { */ fun build(): Intent { val url = generateUrl() - val intent = Intent() - intent.setClass(context, ActivityPayStation::class.java) + var intent = PayStationCache.getInstance(context).getCachedIntent() intent.putExtras( bundleOf( ActivityPayStation.ARG_URL to url, @@ -173,4 +172,5 @@ class XPayments { V3, V4 } + } \ No newline at end of file diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCache.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCache.kt new file mode 100644 index 00000000..491d08d2 --- /dev/null +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCache.kt @@ -0,0 +1,141 @@ +package com.xsolla.android.payments.caching + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import android.view.ViewGroup +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.browser.customtabs.CustomTabsSession +import com.xsolla.android.payments.XPayments +import com.xsolla.android.payments.ui.ActivityPayStation +import com.xsolla.android.payments.ui.utils.BrowserUtils +import com.xsolla.android.payments.ui.utils.CustomTabsHelper +import java.util.Locale + + +class PayStationCache(val context: Context) { + + private lateinit var preloadingWebView:WebView + private var cachedIntent: Intent? = null + private lateinit var customTabHelper: CustomTabsHelper + + fun init() { + XPayments.createIntentBuilder(context) + var locale = Locale.getDefault().language + if(locale.isEmpty()) locale = "en" + if(BrowserUtils.isCustomTabsBrowserAvailable(context)) { + val payStation3WarmUpUrl = "https://secure.xsolla.com/paystation3/$locale/cache-warmup" + val payStation4WarmUpUrl = "https://secure.xsolla.com/paystation4/$locale/cache-warmup" + customTabHelper = CustomTabsHelper(context, payStation3WarmUpUrl, payStation4WarmUpUrl) + customTabHelper.bindCustomTabsService() + } else { + preloadUrl("https://secure.xsolla.com/paystation4/$locale/cache-warmup") + } + } + + fun getCachedIntent(): Intent { + if(cachedIntent == null) { + val newIntent = Intent() + newIntent.setClass(context, ActivityPayStation::class.java) + cachedIntent = newIntent + } + return cachedIntent!! + } + + fun getCachedSession(): CustomTabsSession? { + return customTabHelper.getSession() + } + + private fun preloadUrl(url: String) { + preloadingWebView = prepareWebView(context) + loadUrl(url, preloadingWebView) + } + + private fun prepareWebView(context: Context): WebView { + val webView = WebView(context) + setupWebViewWithDefaults(webView) + return webView + } + + private fun setupWebViewWithDefaults(webView: WebView) { + setWebViewSettings(webView) + setBrowserClients(webView) + } + + private fun setWebViewSettings(webView: WebView?) { + requireNotNull(webView) { "WebView should not be null!" } + webView.settings.javaScriptEnabled = true + webView.settings.domStorageEnabled = true + webView.settings.allowFileAccess = true + webView.settings.loadsImagesAutomatically = true + } + + private fun setBrowserClients(webView: WebView?) { + requireNotNull(webView) { "WebView should not be null!" } + try { + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(webview: WebView, url: String): Boolean { + Log.d(TAG, "shouldOverrideUrlLoading intercept url: $url") + webView.loadUrl(url) + return true + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + val url = view.originalUrl + Toast.makeText(view.context, "Load failed with error: $description", Toast.LENGTH_LONG).show() + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + if (!detail.didCrash()) { + Log.d(TAG, "System killed the WebView rendering process to reclaim memory. Recreating...") + if (view != null) { + val webViewContainer = view.parent as ViewGroup + if (webViewContainer != null && webViewContainer.childCount > 0) { + webViewContainer.removeView(view) + } + view.destroy() + } + return true + } + return false + } + } + webView.webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView, newProgress: Int) { + super.onProgressChanged(view, newProgress) + Log.d(TAG, "onProgressChanged: $newProgress") + if (view != null && newProgress == 100) { + val url = view.originalUrl + Log.d(TAG, "Preloading is done!") + } + } + } + } catch (e: Exception) { + Log.d(TAG, e.message, e) + } + } + + private fun loadUrl(url: String, webView: WebView) { + webView.loadUrl(url) + } + + companion object { + private var instance: PayStationCache? = null + + fun getInstance(context: Context): PayStationCache { + if(instance == null) { + instance = PayStationCache(context) + } + return instance!! + } + + private const val TAG = "PayStationCache" + } +} \ No newline at end of file diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCacheInitializer.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCacheInitializer.kt new file mode 100644 index 00000000..54e94407 --- /dev/null +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/caching/PayStationCacheInitializer.kt @@ -0,0 +1,14 @@ +package com.xsolla.android.payments.caching + +import android.content.Context +import androidx.startup.Initializer + +class PayStationCacheInitializer: Initializer { + override fun create(context: Context): PayStationCache { + return PayStationCache.getInstance(context).apply { init() } + } + + override fun dependencies(): List>> { + return emptyList() + } +} \ No newline at end of file diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/ActivityPayStation.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/ActivityPayStation.kt index 2032ac2e..84995a23 100644 --- a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/ActivityPayStation.kt +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/ActivityPayStation.kt @@ -19,6 +19,7 @@ import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebView.WebViewTransport import android.webkit.WebViewClient +import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import com.xsolla.android.payments.R @@ -41,6 +42,7 @@ internal class ActivityPayStation : AppCompatActivity() { private lateinit var url: String private lateinit var webView: WebView private lateinit var childWebView: WebView + private lateinit var loader: FrameLayout private lateinit var redirectScheme: String private lateinit var redirectHost: String @@ -82,6 +84,7 @@ internal class ActivityPayStation : AppCompatActivity() { setContentView(R.layout.xsolla_payments_activity_paystation) webView = findViewById(R.id.webview) childWebView = findViewById(R.id.childWebView) + loader = findViewById(R.id.loader) configureWebView() webView.loadUrl(url) } else { @@ -193,7 +196,10 @@ internal class ActivityPayStation : AppCompatActivity() { } super.doUpdateVisitedHistory(view, url, isReload) } - + override fun onPageFinished(view: WebView?, url: String?) { + loader.visibility = View.GONE + super.onPageFinished(view, url) + } } webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/BrowserUtils.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/BrowserUtils.kt index 469114bf..7c4ab177 100644 --- a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/BrowserUtils.kt +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/BrowserUtils.kt @@ -9,6 +9,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION import androidx.core.content.ContextCompat import com.xsolla.android.payments.R +import com.xsolla.android.payments.caching.PayStationCache object BrowserUtils { @@ -26,7 +27,7 @@ object BrowserUtils { } } - private fun getAvailableCustomTabsBrowsers(context: Context): List { + fun getAvailableCustomTabsBrowsers(context: Context): List { val browserIntent = Intent() .setAction(Intent.ACTION_VIEW) .addCategory(Intent.CATEGORY_BROWSABLE) @@ -66,14 +67,13 @@ object BrowserUtils { ) .build() - val intent = CustomTabsIntent.Builder() + val customTabsIntent = CustomTabsIntent.Builder(PayStationCache.getInstance(context).getCachedSession()) .setDefaultColorSchemeParams(colorSchemeParams) .setShowTitle(true) .setUrlBarHidingEnabled(true) .build() - intent.intent.`package` = getAvailableCustomTabsBrowsers(context).first() - - intent.launchUrl(context, Uri.parse(url)) + customTabsIntent.intent.setPackage(getAvailableCustomTabsBrowsers(context).first()) + customTabsIntent.launchUrl(context, Uri.parse(url)) } fun launchPlainBrowser(activity: Activity, url: String) { diff --git a/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/CustomTabsHelper.kt b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/CustomTabsHelper.kt new file mode 100644 index 00000000..b9c62698 --- /dev/null +++ b/xsolla-payments-sdk/src/main/java/com/xsolla/android/payments/ui/utils/CustomTabsHelper.kt @@ -0,0 +1,62 @@ +package com.xsolla.android.payments.ui.utils + +import android.content.ComponentName +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession + +class CustomTabsHelper(private val context: Context, private val payStation3WarmUpUrl: String, private val payStation4WarmUpUrl: String) { + + private var customTabsSession: CustomTabsSession? = null + private var mClient: CustomTabsClient? = null + + private var connection: CustomTabsServiceConnection? = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + if (client != null) { + mClient = client + client.warmup(0) + customTabsSession = client.newSession(null) + customTabsSession!!.mayLaunchUrl(Uri.parse(payStation3WarmUpUrl), null, null) + customTabsSession!!.mayLaunchUrl(Uri.parse(payStation4WarmUpUrl), null, null) + + } + } + + override fun onServiceDisconnected(name: ComponentName) { + mClient = null + customTabsSession = null + } + } + + fun bindCustomTabsService() { + + if (mClient != null) return + val availableBrowsers = BrowserUtils.getAvailableCustomTabsBrowsers(context) + + if(availableBrowsers.isEmpty()) return + + val packageName = BrowserUtils.getAvailableCustomTabsBrowsers(context).first() + + CustomTabsClient.bindCustomTabsService(context, packageName, connection!!) + } + + fun unbindCustomTabsService() { + connection ?: return + context.unbindService(connection!!) + mClient = null + customTabsSession = null + connection = null + } + + fun getSession(): CustomTabsSession? { + if (mClient == null) { + customTabsSession = null + } else if (customTabsSession == null) { + customTabsSession = mClient!!.newSession(null) + } + return customTabsSession + } + +} \ No newline at end of file diff --git a/xsolla-payments-sdk/src/main/res/layout/xsolla_payments_activity_paystation.xml b/xsolla-payments-sdk/src/main/res/layout/xsolla_payments_activity_paystation.xml index e106af91..652a9d5a 100644 --- a/xsolla-payments-sdk/src/main/res/layout/xsolla_payments_activity_paystation.xml +++ b/xsolla-payments-sdk/src/main/res/layout/xsolla_payments_activity_paystation.xml @@ -15,4 +15,22 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" /> + + + + + + diff --git a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/XStore.kt b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/XStore.kt index 1e86f128..f5e96450 100644 --- a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/XStore.kt +++ b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/XStore.kt @@ -36,6 +36,7 @@ import retrofit2.Callback import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.Locale class XStore private constructor( private val projectId: Int, @@ -1143,6 +1144,8 @@ class XStore private constructor( * The following languages are supported: Arabic (`ar`), Bulgarian (`bg`), Czech (`cs`), German (`de`), Spanish (`es`), French (`fr`), Hebrew (`he`), Italian (`it`), Japanese (`ja`), Korean (`ko`), Polish (`pl`), Portuguese (`pt`), Romanian (`ro`), Russian (`ru`), Thai (`th`), Turkish (`tr`), Vietnamese (`vi`), Chinese Simplified (`cn`), Chinese Traditional (`tw`), English (`en`, default). * @param additionalFields The list of additional fields. Available fields: `media_list`, `order`, `long_description`. * @param callback Status callback. + * @param requestGeoLocale If `true`, requests the locale based on user's current IP. The backend returns the locale as [VirtualItemsResponse.geoLocale]. + * **IMPORTANT:** Setting [country] argument overrides the returned locale. * @see [More about the use cases](https://developers.xsolla.com/sdk/android/catalog/catalog-display/). */ @JvmStatic @@ -1153,16 +1156,19 @@ class XStore private constructor( offset: Int = 0, locale: String? = null, additionalFields: List? = listOf(), - country: String? = null + country: String? = null, + requestGeoLocale: Boolean? = null ) { - getInstance().storeApi.getVirtualItems( - getInstance().projectId, - limit, - offset, - locale, - additionalFields, - country - ) + getInstance().storeApi + .getVirtualItems( + getInstance().projectId, + limit, + offset, + locale, + additionalFields, + country, + withGeo = requestGeoLocale + ) .enqueue(object : Callback { override fun onResponse( call: Call, @@ -1171,7 +1177,32 @@ class XStore private constructor( if (response.isSuccessful) { val virtualItemsResponse = response.body() if (virtualItemsResponse != null) { - callback.onSuccess(virtualItemsResponse) + val geoLocale = requestGeoLocale?.takeIf { it }?.let { + val headers = response.headers() + + // Retrieve language and country ISO codes. + val localeCode = headers["X-User-Locale-Code"] + val countryCode = headers["X-User-Country-Code"] + + // If both ISO codes are present, only then attempt to parse those into a `Locale`. + if (!localeCode.isNullOrEmpty() && !countryCode.isNullOrEmpty()) { + try { + Locale(localeCode, countryCode) + } catch (_: NullPointerException) { + // We just ignore the locale altogether as it's not essential + // to the `VirtualItemsResponse`'s validity. Moreover, null + // pointer exception might only happen IF the code above is + // broken for some obscure reason, which shouldn't ever happen. + null + } + } else { + null + } + } + + callback.onSuccess( + virtualItemsResponse.copy(geoLocale = geoLocale) + ) } else { callback.onError(null, "Empty response") } diff --git a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/api/StoreApi.kt b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/api/StoreApi.kt index aa052680..60152579 100644 --- a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/api/StoreApi.kt +++ b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/api/StoreApi.kt @@ -255,7 +255,8 @@ internal interface StoreApi { @Query("offset") offset: Int, @Query("locale") locale: String?, @Query("additional_fields") additionalFields: List?, - @Query("country") country: String? + @Query("country") country: String?, + @Query("with_geo") withGeo: Boolean? ): Call @GET("/api/v2/project/{project_id}/items/virtual_items/all") diff --git a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/entity/response/items/VirtualItemsResponse.kt b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/entity/response/items/VirtualItemsResponse.kt index 56f0e7f5..dd6abe24 100644 --- a/xsolla-store-sdk/src/main/java/com/xsolla/android/store/entity/response/items/VirtualItemsResponse.kt +++ b/xsolla-store-sdk/src/main/java/com/xsolla/android/store/entity/response/items/VirtualItemsResponse.kt @@ -5,9 +5,20 @@ import com.google.gson.annotations.SerializedName import com.xsolla.android.store.entity.response.common.* import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue +import java.util.Locale -data class VirtualItemsResponse(@SerializedName("has_more") val hasMore: Boolean = false, val items: List = emptyList()) { +data class VirtualItemsResponse( + /** + * An optional locale returned by the backend based on user's current IP. + * + * Non-null only if request contains the `requestGeoLocale=true` query parameter. + */ + @Transient val geoLocale: Locale? = null, + @SerializedName("has_more") val hasMore: Boolean = false, + + val items: List = emptyList() +) { @Parcelize data class Item( val sku: String? = null,