diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 63458be4f4..696f217e8b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -106,6 +106,10 @@
android:name=".ui.activities.WebViewActivity"
android:parentActivityName=".ui.activities.DiscoveryActivity"
android:theme="@style/WebViewActivity" />
+
isSystemInDarkTheme()
+ AppThemes.DARK.ordinal -> true
+ AppThemes.LIGHT.ordinal -> false
+ else -> false
+ }
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ isSystemInDarkTheme() // Force dark mode uses system theme
+ } else false
+}
+
/**
* if the current context is an instance of Application android base class
* register the callbacks provided on the parameter.
diff --git a/app/src/main/java/com/kickstarter/ui/IntentKey.kt b/app/src/main/java/com/kickstarter/ui/IntentKey.kt
index 0bd2a591ba..563ff6bc00 100644
--- a/app/src/main/java/com/kickstarter/ui/IntentKey.kt
+++ b/app/src/main/java/com/kickstarter/ui/IntentKey.kt
@@ -54,4 +54,5 @@ object IntentKey {
const val RESET_PASSWORD_FACEBOOK_LOGIN = "com.kickstarter.kickstarter.intent_reset_password_facebook"
const val FLAGGINGKIND = "com.kickstarter.kickstarter.intent_report_project"
const val PREVIOUS_SCREEN = "com.kickstarter.kickstarter.previous_screen"
+ const val OAUTH_REDIRECT_URL = "com.kickstarter.kickstarter.oauth_redirect_url"
}
diff --git a/app/src/main/java/com/kickstarter/ui/activities/LoginToutActivity.kt b/app/src/main/java/com/kickstarter/ui/activities/LoginToutActivity.kt
index 7553c53b8d..6477dcfbbf 100644
--- a/app/src/main/java/com/kickstarter/ui/activities/LoginToutActivity.kt
+++ b/app/src/main/java/com/kickstarter/ui/activities/LoginToutActivity.kt
@@ -1,11 +1,13 @@
package com.kickstarter.ui.activities
+import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.browser.customtabs.CustomTabsIntent
@@ -64,6 +66,16 @@ class LoginToutActivity : ComponentActivity() {
private val oAuthLogcat = "OAuth: "
+ private val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ result.data?.let {
+ val url = it.getStringExtra(IntentKey.OAUTH_REDIRECT_URL) ?: ""
+ // - Redirection takes place from WebView, as default browser is not Chrome
+ afterRedirection(url, it)
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var darkModeEnabled = false
@@ -219,8 +231,19 @@ class LoginToutActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Timber.d("$oAuthLogcat onNewIntent Intent: $intent, data: ${intent?.data}")
- // - Intent generated when the deepLink redirection takes place
- intent?.let { oAuthViewModel.produceState(intent = it) }
+ intent?.let {
+ val url = intent.data.toString()
+ // - Redirection takes place from ChromeTab, as the defaultBrowser is Chrome
+ afterRedirection(url, it)
+ }
+ }
+
+ private fun afterRedirection(url: String, intent: Intent) {
+ val uri = Uri.parse(url)
+ uri?.let {
+ if (OAuthViewModel.isAfterRedirectionStep(it))
+ oAuthViewModel.produceState(intent = intent, uri)
+ }
}
private fun setUpOAuthViewModel() {
@@ -228,7 +251,7 @@ class LoginToutActivity : ComponentActivity() {
oAuthViewModel.uiState.collect { state ->
// - Intent generated with onCreate
if (state.isAuthorizationStep && state.authorizationUrl.isNotEmpty()) {
- openChromeTabWithUrl(state.authorizationUrl)
+ openChromeTabOrWebViewWithUrl(state.authorizationUrl)
}
if (state.user.isNotNull()) {
@@ -239,14 +262,25 @@ class LoginToutActivity : ComponentActivity() {
}
}
- private fun openChromeTabWithUrl(url: String) {
+ /**
+ * If default Browser is Chrome a CustomChromeTab will be open with give URL
+ * If default Browser is not Chrome Webview will be open with given URL
+ */
+ private fun openChromeTabOrWebViewWithUrl(url: String) {
val authorizationUri = Uri.parse(url)
val tabIntent = CustomTabsIntent.Builder().build()
val packageName = ChromeTabsHelper.getPackageNameToUse(this)
- tabIntent.intent.setPackage(packageName)
- tabIntent.launchUrl(this, authorizationUri)
+ if (packageName == "com.android.chrome") {
+ tabIntent.intent.setPackage(packageName)
+ tabIntent.launchUrl(this, authorizationUri)
+ } else {
+ val intent: Intent = Intent(this, OAuthWebViewActivity::class.java)
+ .putExtra(IntentKey.URL, authorizationUri.toString())
+ startForResult.launch(intent)
+ this.overridePendingTransition(R.anim.slide_up, R.anim.fade_out)
+ }
}
private fun facebookLoginClick() =
diff --git a/app/src/main/java/com/kickstarter/ui/activities/OAuthWebViewActivity.kt b/app/src/main/java/com/kickstarter/ui/activities/OAuthWebViewActivity.kt
new file mode 100644
index 0000000000..f9c08e4a61
--- /dev/null
+++ b/app/src/main/java/com/kickstarter/ui/activities/OAuthWebViewActivity.kt
@@ -0,0 +1,126 @@
+package com.kickstarter.ui.activities
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.ViewGroup
+import android.webkit.HttpAuthHandler
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.webkit.WebViewDatabase
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.viewinterop.AndroidView
+import com.kickstarter.libs.utils.extensions.getEnvironment
+import com.kickstarter.libs.utils.extensions.isDarkModeEnabled
+import com.kickstarter.libs.utils.extensions.isNotNull
+import com.kickstarter.ui.IntentKey
+import com.kickstarter.ui.compose.designsystem.KickstarterApp
+import com.kickstarter.ui.extensions.text
+import com.kickstarter.viewmodels.OAuthViewModel
+
+/**
+ * Will be used for OAuth when default Browser is not Chrome
+ * with other browsers (Firefox, Opera, Arc, Duck Duck Go ... etc) even based in Chromium
+ * the redirection was not triggered on `LoginToutActivity.onNewIntent`, and the customTabInstance was never killed.
+ */
+class OAuthWebViewActivity : ComponentActivity() {
+ val callback: (String) -> Unit = { inputString ->
+ val intent = Intent()
+ .putExtra(IntentKey.OAUTH_REDIRECT_URL, inputString)
+ this.setResult(Activity.RESULT_OK, intent)
+ this.finish()
+ }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val url = intent.getStringExtra(IntentKey.URL) ?: ""
+ this.getEnvironment()?.let { env ->
+ setContent {
+ val darModeEnabled = this.isDarkModeEnabled(env = env)
+ KickstarterApp(useDarkTheme = darModeEnabled) {
+ WebView(url, this, callback)
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun WebView(url: String, context: Context, callback: (String) -> Unit) {
+ AndroidView(factory = {
+ WebView(it).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ this.webViewClient = CustomWebViewClient(context = context, callback)
+ this.settings.allowFileAccess = true
+ }
+ }, update = {
+ it.loadUrl(url)
+ })
+ }
+}
+
+class CustomWebViewClient(private val context: Context, private val callback: (String) -> Unit) : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ request?.url?.let {
+ if (OAuthViewModel.isAfterRedirectionStep(it)) {
+ callback(it.toString())
+ }
+ } ?: callback("")
+ return false
+ }
+
+ /**
+ * Only used on staging environments for basic http authentication
+ */
+ @RequiresApi(Build.VERSION_CODES.O)
+ override fun onReceivedHttpAuthRequest(
+ view: WebView?,
+ handler: HttpAuthHandler?,
+ host: String?,
+ realm: String?
+ ) {
+ val webDatabase = WebViewDatabase.getInstance(context)
+
+ val alert: AlertDialog.Builder = AlertDialog.Builder(context)
+
+ val container = LinearLayout(context)
+ container.orientation = LinearLayout.VERTICAL
+ val user = EditText(context)
+ user.hint = "Staging credential User:"
+ val password = EditText(context)
+ password.hint = "Staging credential Password:"
+
+ container.addView(user)
+ container.addView(password)
+
+ alert.setView(container)
+ alert.setPositiveButton(
+ "Send",
+ DialogInterface.OnClickListener { dialog, whichButton ->
+ if (user.isNotNull() && password.isNotNull()) {
+ webDatabase.setHttpAuthUsernamePassword(
+ host,
+ realm,
+ user.text(),
+ password.text()
+ )
+ handler?.proceed(user.text(), password.text())
+ super.onReceivedHttpAuthRequest(view, handler, host, realm)
+ }
+ }
+ )
+ alert.show()
+ }
+}
diff --git a/app/src/main/java/com/kickstarter/ui/activities/WebViewActivity.kt b/app/src/main/java/com/kickstarter/ui/activities/WebViewActivity.kt
index 041b36d8ed..6f72667a69 100644
--- a/app/src/main/java/com/kickstarter/ui/activities/WebViewActivity.kt
+++ b/app/src/main/java/com/kickstarter/ui/activities/WebViewActivity.kt
@@ -6,6 +6,7 @@ import androidx.activity.addCallback
import com.kickstarter.databinding.WebViewLayoutBinding
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.extensions.finishWithAnimation
+import com.kickstarter.ui.views.KSWebView
class WebViewActivity : ComponentActivity() {
private lateinit var binding: WebViewLayoutBinding
@@ -17,6 +18,21 @@ class WebViewActivity : ComponentActivity() {
val toolbarTitle = intent.getStringExtra(IntentKey.TOOLBAR_TITLE)
toolbarTitle?.let { binding.webViewToolbar.webViewToolbar.setTitle(it) }
+
+ binding.webView.setDelegate(object : KSWebView.Delegate {
+ override fun externalLinkActivated(url: String) {
+ }
+
+ override fun pageIntercepted(url: String) {
+ if (url.contains("authenticate")) {
+ finish()
+ }
+ }
+
+ override fun onReceivedError(url: String) {
+ }
+ })
+
val url = intent.getStringExtra(IntentKey.URL)
url?.let { binding.webView.loadUrl(it) }
diff --git a/app/src/main/java/com/kickstarter/viewmodels/OAuthViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/OAuthViewModel.kt
index d5d34fc76a..b6dd400195 100644
--- a/app/src/main/java/com/kickstarter/viewmodels/OAuthViewModel.kt
+++ b/app/src/main/java/com/kickstarter/viewmodels/OAuthViewModel.kt
@@ -46,6 +46,7 @@ class OAuthViewModel(
private val logcat = "Oauth :"
private val hostEndpoint = environment.webEndpoint()
+ private val sharedPreferences = environment.sharedPreferences()
private val loginUseCase = LoginUseCase(environment)
private val analyticEvents = requireNotNull(environment.analytics())
private val apiClient = requireNotNull(environment.apiClientV2())
@@ -56,7 +57,16 @@ class OAuthViewModel(
}
private var mutableUIState = MutableStateFlow(OAuthUiState())
- private var codeVerifier: String? = null
+ private var codeVerifier: String?
+ get() {
+ return sharedPreferences?.getString("KSCodeVerifier", "")
+ }
+ set(value) {
+ if (value == null) sharedPreferences?.edit()?.remove("KSCodeVerifier")?.apply()
+ else sharedPreferences?.edit()
+ ?.putString("KSCodeVerifier", value)
+ ?.apply()
+ }
val uiState: StateFlow
get() = mutableUIState.asStateFlow()
.stateIn(
@@ -66,54 +76,71 @@ class OAuthViewModel(
)
@OptIn(ExperimentalCoroutinesApi::class)
- fun produceState(intent: Intent) {
+ fun produceState(intent: Intent, uri: Uri? = null) {
viewModelScope.launch {
- val uri = Uri.parse(intent.data.toString())
- val scheme = uri.scheme
- val host = uri.host
- val code = uri.getQueryParameter("code")
-
- if (isAfterRedirectionStep(scheme, host, code, codeVerifier)) {
- Timber.d("$logcat retrieve token after redirectionDeeplink: $code")
- apiClient.loginWithCodes(requireNotNull(codeVerifier), requireNotNull(code), clientID)
- .asFlow()
- .flatMapLatest { token ->
- Timber.d("$logcat About to persist token to currentUser: $token")
- loginUseCase.setToken(token.accessToken())
- apiClient.fetchCurrentUser()
- .asFlow()
- .map {
- it
- }
- }
- .catch {
- Timber.e("$logcat error while getting the token or user: ${processThrowable(it)}")
- mutableUIState.emit(
- OAuthUiState(
- error = processThrowable(it),
- user = null
+ uri?.let {
+ val code = uri.getQueryParameter("code")
+ if (isAfterRedirectionStep(uri)) {
+ Timber.d("$logcat retrieve token after redirectionDeeplink: $code")
+ apiClient.loginWithCodes(
+ requireNotNull(codeVerifier),
+ requireNotNull(code),
+ clientID
+ )
+ .asFlow()
+ .flatMapLatest { token ->
+ Timber.d("$logcat About to persist token to currentUser: $token")
+ loginUseCase.setToken(token.accessToken())
+ apiClient.fetchCurrentUser()
+ .asFlow()
+ .map {
+ it
+ }
+ }
+ .catch {
+ Timber.e(
+ "$logcat error while getting the token or user: ${
+ processThrowable(
+ it
+ )
+ }"
)
- )
- loginUseCase.logout()
- }
- .collect { user ->
- Timber.d("$logcat About to persist user to currentUser: $user")
- loginUseCase.setUser(user)
- mutableUIState.emit(
- OAuthUiState(
- user = user,
+ mutableUIState.emit(
+ OAuthUiState(
+ error = processThrowable(it),
+ user = null
+ )
+ )
+ loginUseCase.logout()
+ codeVerifier = null
+ }
+ .collect { user ->
+ Timber.d("$logcat About to persist user to currentUser: $user")
+ loginUseCase.setUser(user)
+ mutableUIState.emit(
+ OAuthUiState(
+ user = user,
+ )
)
+ analyticEvents.trackLogInButtonCtaClicked()
+ codeVerifier = null
+ }
+ } else {
+ mutableUIState.emit(
+ OAuthUiState(
+ error = "$code / $codeVerifier empty or null or wrong redirection",
+ user = null
)
- analyticEvents.trackLogInButtonCtaClicked()
- }
- } else {
- mutableUIState.emit(OAuthUiState(error = "$code / $codeVerifier empty or null or wrong redirection", user = null))
+ )
+ codeVerifier = null
+ }
}
- if (intent.data == null) {
+ if (intent.data == null && uri == null) {
+ codeVerifier = null
codeVerifier = verifier.generateRandomCodeVerifier(entropy = CodeVerifier.MIN_CODE_VERIFIER_ENTROPY)
- codeVerifier?.let { verifier ->
- val url = generateAuthorizationUrlWithParams(verifier)
+ codeVerifier?.let {
+ val url = generateAuthorizationUrlWithParams(it)
Timber.d("$logcat isAuthorizationStep $url and codeVerifier: $codeVerifier")
mutableUIState.emit(
OAuthUiState(
@@ -125,8 +152,6 @@ class OAuthViewModel(
}
}
}
- fun isAfterRedirectionStep(scheme: String?, host: String?, code: String?, codeVerifier: String?) =
- scheme == REDIRECT_URI_SCHEMA && host == REDIRECT_URI_HOST && !code.isNullOrBlank() && !codeVerifier.isNullOrBlank()
private fun processThrowable(throwable: Throwable): String {
if (!throwable.message.isNullOrBlank()) return throwable.message ?: ""
@@ -152,9 +177,15 @@ class OAuthViewModel(
return "$hostEndpoint/oauth/authorizations/new?$authParams"
}
- private companion object {
+ companion object {
const val REDIRECT_URI_SCHEMA = "ksrauth2"
const val REDIRECT_URI_HOST = "authenticate"
+ fun isAfterRedirectionStep(uri: Uri): Boolean {
+ val scheme = uri.scheme
+ val host = uri.host
+ val code = uri.getQueryParameter("code")
+ return scheme == REDIRECT_URI_SCHEMA && host == REDIRECT_URI_HOST && !code.isNullOrBlank()
+ }
}
}
diff --git a/app/src/test/java/com/kickstarter/viewmodels/OAuthViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/OAuthViewModelTest.kt
index 852de8fa51..32c47d9fd9 100644
--- a/app/src/test/java/com/kickstarter/viewmodels/OAuthViewModelTest.kt
+++ b/app/src/test/java/com/kickstarter/viewmodels/OAuthViewModelTest.kt
@@ -111,7 +111,7 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
val redirectionUrl = "ksrauth2://authenticate?&redirect_uri=ksrauth2&response_type=1&scope=1"
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- vm.produceState(Intent().setData(Uri.parse(redirectionUrl)))
+ vm.produceState(Intent(), Uri.parse(redirectionUrl))
vm.uiState.toList(state)
}
@@ -168,7 +168,7 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.produceState(Intent())
- vm.produceState(Intent().setData(Uri.parse(redirectionUrl)))
+ vm.produceState(Intent(), Uri.parse(redirectionUrl))
vm.uiState.toList(state)
}
@@ -214,7 +214,7 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.produceState(Intent())
- vm.produceState(Intent().setData(Uri.parse(redirectionUrl)))
+ vm.produceState(Intent(), Uri.parse(redirectionUrl))
vm.uiState.toList(state)
}
@@ -270,7 +270,7 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.produceState(Intent())
- vm.produceState(Intent().setData(Uri.parse(redirectionUrl)))
+ vm.produceState(Intent(), Uri.parse(redirectionUrl))
vm.uiState.toList(state)
}
@@ -322,7 +322,7 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
val redirectionUrl = "ksrauth2://authenticate?code=$testCode&redirect_uri=ksrauth2&response_type=1&scope=1"
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
- vm.produceState(Intent().setData(Uri.parse(redirectionUrl)))
+ vm.produceState(Intent(), Uri.parse(redirectionUrl))
vm.uiState.toList(state)
}
@@ -344,7 +344,8 @@ class OAuthViewModelTest : KSRobolectricTestCase() {
fun `test invalid information after redirection`() {
setUpEnvironment(environment(), CodeVerifier())
- assertFalse(vm.isAfterRedirectionStep(null, null, null, null))
- assertFalse(vm.isAfterRedirectionStep("http", "someHost.com", "", ""))
+ assertFalse(OAuthViewModel.isAfterRedirectionStep(Uri.parse("")))
+ assertFalse(OAuthViewModel.isAfterRedirectionStep(Uri.parse("http://someurelrandom")))
+ assertTrue(OAuthViewModel.isAfterRedirectionStep(Uri.parse("ksrauth2://authenticate?code=12345")))
}
}