Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ object VirtusizeAuth {
resultCode: Int,
data: Intent?,
) {
val script = data?.getStringExtra(EXTRA_NAME_SNS_SCRIPT)
android.util.Log.d(
"VsAuth",
"handleVirtusizeSNSAuthResult resultCode=$resultCode ok=${resultCode == Activity.RESULT_OK} " +
"scriptPresent=${script != null}",
)
if (resultCode == Activity.RESULT_OK) {
data?.getStringExtra(EXTRA_NAME_SNS_SCRIPT)?.let { snsScript ->
script?.let { snsScript ->
view.evaluateJavascript(snsScript, null)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package com.virtusize.android.auth.views

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION
import com.virtusize.android.auth.data.SnsType
import com.virtusize.android.auth.data.VirtusizeUser
import com.virtusize.android.auth.network.FacebookAPIService
Expand All @@ -31,13 +31,16 @@ import java.net.URLDecoder

internal class VitrusizeAuthActivity : AppCompatActivity() {
companion object {
private const val TAG = "VsAuth"
private const val QUERY_REDIRECT_URI_KEY = "redirect_uri"
private const val QUERY_CHANNEL_URL_KEY = "channel_url"
}

private lateinit var viewModel: VirtusizeAuthViewModel
private var customTabIsOpened = false
private var webView: WebView? = null
private var redirectHandled = false

@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand Down Expand Up @@ -114,51 +117,65 @@ internal class VitrusizeAuthActivity : AppCompatActivity() {
env.lowercase(),
)

if (deviceSupportsChromeCustomTabs(this)) {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(this, uri)
} else {
val launchUrlInBrowser = Intent(Intent.ACTION_VIEW, uri)
startActivity(launchUrlInBrowser)
}
// Render the SNS sign-in flow in an embedded WebView instead of a Chrome Custom
// Tab. The Custom Tab showed a blank screen after the user picked a Google account
// (Chrome blocks the automatic redirect back to the app's custom scheme without a
// user gesture). A WebView using the device's default user agent is accepted by
// Google and lets us intercept the final redirect ourselves. See the working
// iceberg-native SDK which uses the same embedded-WebView approach.
Log.d(TAG, "onCreate loading auth url: $uri")
setContentView(createAuthWebView().also { webView = it })
webView?.loadUrl(uri.toString())
}
else -> finish()
}

subscribeUI()
}

/**
* Returns a list of packages that support Custom Tabs.
*
* Ref: https://developer.chrome.com/docs/android/custom-tabs/integration-guide/#how-can-i-check-whether-the-android-device-has-a-browser-that-supports-custom-tab
*
* @param context
* @return true if the device supports chrome custom tabs
*/
private fun deviceSupportsChromeCustomTabs(context: Context): Boolean {
val pm: PackageManager = context.packageManager
// Get default VIEW intent handler.
val activityIntent =
Intent()
.setAction(Intent.ACTION_VIEW)
.addCategory(Intent.CATEGORY_BROWSABLE)
.setData(Uri.parse("https://"))

// Get all apps that can handle VIEW intents.
val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0)
val packagesSupportingCustomTabs: ArrayList<ResolveInfo> = ArrayList()
for (info in resolvedActivityList) {
val serviceIntent = Intent()
serviceIntent.action = ACTION_CUSTOM_TABS_CONNECTION
serviceIntent.setPackage(info.activityInfo.packageName)
// Check if this package also resolves the Custom Tabs service.
if (pm.resolveService(serviceIntent, 0) != null) {
packagesSupportingCustomTabs.add(info)
}
@SuppressLint("SetJavaScriptEnabled")
private fun createAuthWebView(): WebView =
WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
// Use the device's default browser user agent (not the SDK Safari UA) so providers
// such as Google do not reject the embedded browser.
settings.userAgentString = System.getProperty("http.agent")
webViewClient =
object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val url = request.url
val scheme = url.scheme?.lowercase()
Log.d(TAG, "shouldOverrideUrlLoading scheme=$scheme url=$url")
// The SNS proxy redirects to the app's custom scheme
// (e.g. com.company.app.virtusize://sns-auth?...) once the provider
// sign-in completes. Consume that redirect and deliver the result.
if (scheme != null && scheme != "http" && scheme != "https") {
handleRedirect(url)
return true
}
return false
}

override fun onPageFinished(
view: WebView?,
url: String?,
) {
super.onPageFinished(view, url)
Log.d(TAG, "onPageFinished url=$url")
// Defensive: some SNS proxy pages deliver the result to the opener via
// postMessage and never navigate to the app's custom scheme. When loaded
// directly (no opener) that leaves a blank page, so also try to consume the
// token straight from the loaded URL.
url?.let { handleRedirect(Uri.parse(it)) }
}
}
}
return packagesSupportingCustomTabs.isNotEmpty()
}

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Expand All @@ -167,14 +184,35 @@ internal class VitrusizeAuthActivity : AppCompatActivity() {

override fun onResume() {
super.onResume()
val redirectURL = intent.data
// Fallback for the manifest deep-link path (e.g. when the flow falls back to an external
// browser): consume a redirect URI we were resumed with. No-op on the initial launch.
handleRedirect(intent.data)
}

override fun onDestroy() {
webView?.destroy()
webView = null
super.onDestroy()
}

/**
* Handles the SNS redirect URI carrying the access token (or code) and the `state` params.
*
* @return true if [redirectURL] was a valid SNS redirect that we consumed.
*/
private fun handleRedirect(redirectURL: Uri?): Boolean {
val accessToken =
redirectURL?.getQueryParameter(SNS_ACCESS_TOKEN_KEY)
?: redirectURL?.getQueryParameter(SNS_CODE_KEY)
if (redirectURL == null || accessToken == null) {
handleCustomTabToggleAndFinish()
return
return false
}
// Both shouldOverrideUrlLoading and onPageFinished may surface the same redirect; only
// process it once so we don't fire the user-info request (and finish) twice.
if (redirectHandled) {
return true
}
redirectHandled = true

// Restore SNS params from `state` parameter as json
val stateMap = VirtusizeUriHelper.getStateMap(redirectURL)
Expand All @@ -185,6 +223,7 @@ internal class VitrusizeAuthActivity : AppCompatActivity() {
val region = stateMap[SNS_REGION_KEY] as? String
val env = stateMap[SNS_ENV_KEY] as? String
val redirectUrl = VirtusizeUriHelper.getRedirectUrl(region, env)
Log.d(TAG, "handleRedirect token=present snsType=$snsType region=$region env=$env")

when (snsType) {
SnsType.GOOGLE, SnsType.FACEBOOK -> {
Expand All @@ -201,19 +240,12 @@ internal class VitrusizeAuthActivity : AppCompatActivity() {
}
null -> finish() // cancel the flow if the SNS Provider can't be resolved
}
}

private fun handleCustomTabToggleAndFinish() {
if (customTabIsOpened) {
customTabIsOpened = false
finish()
} else {
customTabIsOpened = true
}
return true
}

private fun subscribeUI() {
viewModel.virtusizeUser.observe(this) { user ->
Log.d(TAG, "getUserInfo result user=${if (user != null) "ok(${user.email})" else "null"}; finishing")
if (user != null) {
val data = Intent()
data.putExtra(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
Expand Down Expand Up @@ -163,60 +164,72 @@ class VirtusizeWebViewFragment : DialogFragment() {
userGesture: Boolean,
resultMsg: Message,
): Boolean {
if (resultMsg.obj != null && resultMsg.obj is WebView.WebViewTransport) {
val popupWebView =
WebView(view.context).apply {
setBackgroundColor(Color.TRANSPARENT)
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
settings.domStorageEnabled = true
settings.userAgentString = System.getProperty("http.agent")
webViewClient =
object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
url: String,
): Boolean {
if (VirtusizeURLCheck.isExternalLinkFromVirtusize(url)) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
try {
startActivity(intent)
} finally {
return true
}
}
if (showSNSButtons) {
return VirtusizeAuth.isSNSAuthUrl(
requireContext(),
virtusizeSNSAuthLauncher,
url,
).also { isSNSAuthUrl ->
if (isSNSAuthUrl) {
binding.webView.removeAllViews()
}
}
}
return false
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
val popupWebView =
WebView(view.context).apply {
setBackgroundColor(Color.TRANSPARENT)
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
settings.domStorageEnabled = true
// Device default UA (not the SDK Safari UA) so providers such as Google
// do not reject the embedded browser. See https://stackoverflow.com/a/73152331
settings.userAgentString = System.getProperty("http.agent")
isFocusableInTouchMode = true
setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == MotionEvent.ACTION_UP) {
if (canGoBack()) goBack() else removePopupWebView(this)
true
} else {
false
}
}
webViewClient =
object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
url: String,
): Boolean {
val scheme = Uri.parse(url).scheme?.lowercase()
// Hand non-http(s) deep links (e.g. SNS app schemes) to the OS.
if (scheme != null && scheme != "http" && scheme != "https") {
return runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
true
}.getOrDefault(false)
}
}
webChromeClient =
object : WebChromeClient() {
override fun onCloseWindow(window: WebView) {
binding.webView.removeAllViews()
if (VirtusizeURLCheck.isExternalLinkFromVirtusize(url)) {
return runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
true
}.getOrDefault(false)
}
// Keep the SNS sign-in (Google/Apple/LINE) inside this popup so the
// window.opener relationship is preserved and the SNS proxy can post
// the auth result back to the aoyama widget. This mirrors the working
// iceberg-native SDK; diverting to a standalone VitrusizeAuthActivity
// breaks window.opener and leaves a blank page after Google login.
return false
}
}
}
webChromeClient =
object : WebChromeClient() {
override fun onCloseWindow(window: WebView) {
removePopupWebView(window)
}
}
}

val transport = resultMsg.obj as WebView.WebViewTransport
transport.webView = popupWebView
resultMsg.sendToTarget()
}
// Attach the popup as an overlay on the root container (not on the main WebView,
// whose i18n handler calls removeAllViews) so it actually renders.
(binding.root as ViewGroup).addView(popupWebView)
transport.webView = popupWebView
resultMsg.sendToTarget()
return true
}
}
Expand Down Expand Up @@ -314,6 +327,14 @@ class VirtusizeWebViewFragment : DialogFragment() {
virtusizeMessageHandler = messageHandler
}

/**
* Removes a transient SNS sign-in popup [WebView] from the root container and releases it.
*/
private fun removePopupWebView(popup: WebView) {
(binding.root as? ViewGroup)?.removeView(popup)
popup.destroy()
}

/**
* Returns virtusize.bid from the web view cookies
*/
Expand Down
Loading