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"))) } }