From db9f82efd81bf14a09a8dde2cc4ff42a1c46876e Mon Sep 17 00:00:00 2001 From: Eva Tatarka Date: Wed, 21 Dec 2022 21:34:35 -0800 Subject: [PATCH] Authenticate using an external browser on desktop --- app-android/build.gradle.kts | 1 + app-android/src/main/AndroidManifest.xml | 2 +- .../social/androiddev/dodo/MainActivity.kt | 14 +++ .../androiddev/dodo/di/AndroidAppModule.kt | 8 +- .../androiddev/dodo/web/CustomTabWebAuth.kt | 51 +++++++++++ app-desktop/build.gradle.kts | 2 + .../kotlin/social/androiddev/desktop/Main.kt | 3 +- .../androiddev/desktop/di/DesktopAppModule.kt | 9 ++ .../desktop/web/ExternalBrowserWebAuth.kt | 81 +++++++++++++++++ ...ocial.androiddev.android.common.gradle.kts | 2 +- .../AuthenticationRepositoryImpl.kt | 2 +- di/build.gradle.kts | 1 + .../androiddev/common/web/WebOpenExtras.kt | 3 + .../social/androiddev/common/web/WebAuth.kt | 18 ++++ .../androiddev/common/web/WebOpenExtras.kt | 3 + .../androiddev/common/web/WebOpenExtras.kt | 3 + domain/authentication/build.gradle.kts | 7 ++ gradle/libs.versions.toml | 3 + ui/signed-out/build.gradle.kts | 1 + .../signedout/selectserver/redirectScheme.kt | 16 ---- .../signedout/selectserver/webOpenExtras.kt | 14 +++ .../root/DefaultSignedOutRootComponent.kt | 5 +- .../DefaultSelectServerComponent.kt | 34 +++++-- .../signedout/selectserver/RedirectScheme.kt | 12 --- .../selectserver/SelectServerComponent.kt | 8 +- .../selectserver/SelectServerContent.kt | 80 +++++++++++++++-- .../selectserver/SelectServerViewModel.kt | 88 +++++++++++++++++-- .../signedout/selectserver/redirectScheme.kt | 12 --- .../signedout/selectserver/webOpenExtras.kt | 9 ++ 29 files changed, 422 insertions(+), 70 deletions(-) create mode 100644 app-android/src/main/kotlin/social/androiddev/dodo/web/CustomTabWebAuth.kt create mode 100644 app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/di/DesktopAppModule.kt create mode 100644 app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/web/ExternalBrowserWebAuth.kt create mode 100644 di/src/androidMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt create mode 100644 di/src/commonMain/kotlin/social/androiddev/common/web/WebAuth.kt create mode 100644 di/src/commonMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt create mode 100644 di/src/desktopMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt delete mode 100644 ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt create mode 100644 ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt delete mode 100644 ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/RedirectScheme.kt delete mode 100644 ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt create mode 100644 ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index c60b6e8e..cc85e515 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -27,4 +27,5 @@ dependencies { implementation(libs.io.insert.koin.android) implementation(libs.com.arkivanov.decompose) implementation(libs.com.arkivanov.decompose.extensions.compose.jetbrains) + implementation(libs.androidx.browser) } diff --git a/app-android/src/main/AndroidManifest.xml b/app-android/src/main/AndroidManifest.xml index 054eec07..41f64d5a 100644 --- a/app-android/src/main/AndroidManifest.xml +++ b/app-android/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ - + diff --git a/app-android/src/main/kotlin/social/androiddev/dodo/MainActivity.kt b/app-android/src/main/kotlin/social/androiddev/dodo/MainActivity.kt index 18048de3..90b5ed1d 100644 --- a/app-android/src/main/kotlin/social/androiddev/dodo/MainActivity.kt +++ b/app-android/src/main/kotlin/social/androiddev/dodo/MainActivity.kt @@ -10,20 +10,29 @@ package social.androiddev.dodo import android.os.Bundle +import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import com.arkivanov.decompose.defaultComponentContext import kotlinx.coroutines.Dispatchers +import org.koin.core.context.loadKoinModules +import org.koin.core.context.unloadKoinModules +import org.koin.dsl.module import social.androiddev.common.theme.DodoTheme import social.androiddev.root.composables.RootContent import social.androiddev.root.navigation.DefaultRootComponent class MainActivity : AppCompatActivity() { + private val activityModule = module { + factory { this@MainActivity } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + loadKoinModules(activityModule) // Create the root component before starting Compose val root = DefaultRootComponent( @@ -40,4 +49,9 @@ class MainActivity : AppCompatActivity() { } } } + + override fun onDestroy() { + super.onDestroy() + unloadKoinModules(activityModule) + } } diff --git a/app-android/src/main/kotlin/social/androiddev/dodo/di/AndroidAppModule.kt b/app-android/src/main/kotlin/social/androiddev/dodo/di/AndroidAppModule.kt index 558d7e91..0dfa7d32 100644 --- a/app-android/src/main/kotlin/social/androiddev/dodo/di/AndroidAppModule.kt +++ b/app-android/src/main/kotlin/social/androiddev/dodo/di/AndroidAppModule.kt @@ -10,9 +10,15 @@ package social.androiddev.dodo.di import org.koin.dsl.module +import social.androiddev.common.web.WebAuth +import social.androiddev.dodo.web.CustomTabWebAuth /** * The Dodo Android app Koin module holding koin definitions * specific to the android app */ -val androidModule = module { } +val androidModule = module { + factory { + CustomTabWebAuth(activity = get()) + } +} diff --git a/app-android/src/main/kotlin/social/androiddev/dodo/web/CustomTabWebAuth.kt b/app-android/src/main/kotlin/social/androiddev/dodo/web/CustomTabWebAuth.kt new file mode 100644 index 00000000..8f217ddf --- /dev/null +++ b/app-android/src/main/kotlin/social/androiddev/dodo/web/CustomTabWebAuth.kt @@ -0,0 +1,51 @@ +package social.androiddev.dodo.web + +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.util.Consumer +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import social.androiddev.common.web.WebAuth +import social.androiddev.common.web.WebOpenExtras + +class CustomTabWebAuth(private val activity: ComponentActivity) : WebAuth { + + private val _state = MutableSharedFlow(replay = 1) + override val state: Flow = _state + + override suspend fun start(): String { + val onNewIntentListener = Consumer { intent -> + activity.lifecycleScope.launch { + val code = intent.data?.getQueryParameter("code") + if (code != null) { + _state.emit(WebAuth.State.Success(code)) + } + val error = intent.data?.getQueryParameter("error") + val errorDescription = intent.data?.getQueryParameter("error_description") + if (error != null) { + _state.emit(WebAuth.State.Error(errorDescription)) + } + } + } + activity.addOnNewIntentListener(onNewIntentListener) + return "dodooauth2redirect://callback" + } + + override fun open(uri: String, extras: WebOpenExtras) { + CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(extras.primaryColor) + .setSecondaryToolbarColor(extras.secondaryColor) + .build() + ) + .build() + .launchUrl(activity, Uri.parse(uri)) + } +} \ No newline at end of file diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index 8ff7ffd9..b18a3db5 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -23,6 +23,8 @@ kotlin { implementation(libs.io.insert.koin.core) implementation(libs.kotlinx.coroutines.javafx) implementation(libs.kotlinx.coroutines.core) + implementation(libs.io.ktor.server.core) + implementation(libs.io.ktor.server.netty) } } } diff --git a/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/Main.kt b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/Main.kt index a077ff90..28218b65 100644 --- a/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/Main.kt +++ b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/Main.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import org.koin.core.context.startKoin import social.androiddev.common.di.appModule import social.androiddev.common.theme.DodoTheme +import social.androiddev.desktop.di.desktopModule import social.androiddev.root.composables.RootContent import social.androiddev.root.navigation.DefaultRootComponent @@ -29,7 +30,7 @@ import social.androiddev.root.navigation.DefaultRootComponent fun main() { startKoin { - modules(appModule()) + modules(appModule() + desktopModule) } val lifecycle = LifecycleRegistry() diff --git a/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/di/DesktopAppModule.kt b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/di/DesktopAppModule.kt new file mode 100644 index 00000000..2a5c12fc --- /dev/null +++ b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/di/DesktopAppModule.kt @@ -0,0 +1,9 @@ +package social.androiddev.desktop.di + +import org.koin.dsl.module +import social.androiddev.common.web.WebAuth +import social.androiddev.desktop.web.ExternalBrowserWebAuth + +val desktopModule = module { + factory { ExternalBrowserWebAuth() } +} \ No newline at end of file diff --git a/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/web/ExternalBrowserWebAuth.kt b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/web/ExternalBrowserWebAuth.kt new file mode 100644 index 00000000..481683c8 --- /dev/null +++ b/app-desktop/src/jvmMain/kotlin/social/androiddev/desktop/web/ExternalBrowserWebAuth.kt @@ -0,0 +1,81 @@ +package social.androiddev.desktop.web + +import androidx.compose.ui.res.painterResource +import io.ktor.server.application.call +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import social.androiddev.common.web.WebAuth +import social.androiddev.common.web.WebOpenExtras + +private val Browsers = arrayOf( + "xdg-open", + "google-chrome", + "firefox", + "opera", + "konqueror", + "mozilla" +) + +class ExternalBrowserWebAuth : WebAuth { + + private val _state = MutableSharedFlow(replay = 1) + override val state: Flow = _state + + override suspend fun start(): String { + val server = io.ktor.server.engine.embeddedServer(io.ktor.server.netty.Netty, port = 0) { + routing { + get("/callback") { + val code = call.request.queryParameters["code"] + if (code != null) { + _state.emit(WebAuth.State.Success(code)) + this@embeddedServer.dispose() + call.respond("Success! You may close the tab") + } + val error = call.request.queryParameters["error"] + val errorDescription = call.request.queryParameters["error_description"] + if (error != null) { + _state.emit(WebAuth.State.Error(errorDescription)) + call.respond("$error: $errorDescription") + } + } + } + } + server.start() + val port = server.resolvedConnectors().first().port + return "http://localhost:${port}/callback" + } + + override fun open(uri: String, extras: WebOpenExtras) { + val osName = System.getProperty("os.name") + try { + if (osName.startsWith("Mac OS")) { + Runtime.getRuntime().exec( + "open $uri" + ) + } else if (osName.startsWith("Windows")) { + Runtime.getRuntime().exec( + "rundll32 url.dll,FileProtocolHandler $uri" + ) + } else { //assume Unix or Linux + var browser: String? = null + for (b in Browsers) { + if (browser == null && Runtime.getRuntime() + .exec(arrayOf("which", b)).inputStream.read() != -1 + ) { + Runtime.getRuntime().exec(arrayOf(b.also { browser = it }, uri)) + } + } + if (browser == null) { + throw Exception("No web browser found") + } + } + } catch (e: Exception) { + // should not happen + // dump stack for debug purpose + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/build-logic/convention-plugins/src/main/kotlin/social.androiddev.android.common.gradle.kts b/build-logic/convention-plugins/src/main/kotlin/social.androiddev.android.common.gradle.kts index 306df756..d633d9bc 100644 --- a/build-logic/convention-plugins/src/main/kotlin/social.androiddev.android.common.gradle.kts +++ b/build-logic/convention-plugins/src/main/kotlin/social.androiddev.android.common.gradle.kts @@ -10,7 +10,7 @@ android { compileSdk = 33 defaultConfig { - minSdk = 33 + minSdk = 23 } // targetSdk is in a different interface for library and application projects diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/AuthenticationRepositoryImpl.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/AuthenticationRepositoryImpl.kt index 0e0ca4fb..12fd660e 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/AuthenticationRepositoryImpl.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/AuthenticationRepositoryImpl.kt @@ -108,5 +108,5 @@ internal class AuthenticationRepositoryImpl( } } - override val selectedServer: String? = settings.currentDomain + override val selectedServer: String? get() = settings.currentDomain } diff --git a/di/build.gradle.kts b/di/build.gradle.kts index bad71d46..acc3ac73 100644 --- a/di/build.gradle.kts +++ b/di/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { implementation(projects.data.repository) implementation(projects.domain.authentication) implementation(libs.io.insert.koin.core) + implementation(libs.kotlinx.coroutines.core) } } } diff --git a/di/src/androidMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt b/di/src/androidMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt new file mode 100644 index 00000000..27909452 --- /dev/null +++ b/di/src/androidMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt @@ -0,0 +1,3 @@ +package social.androiddev.common.web + +actual class WebOpenExtras(val primaryColor: Int, val secondaryColor: Int) \ No newline at end of file diff --git a/di/src/commonMain/kotlin/social/androiddev/common/web/WebAuth.kt b/di/src/commonMain/kotlin/social/androiddev/common/web/WebAuth.kt new file mode 100644 index 00000000..3f5a2991 --- /dev/null +++ b/di/src/commonMain/kotlin/social/androiddev/common/web/WebAuth.kt @@ -0,0 +1,18 @@ +package social.androiddev.common.web + +import kotlinx.coroutines.flow.Flow + +interface WebAuth { + + val state: Flow + + suspend fun start(): String + + fun open(uri: String, extras: WebOpenExtras) + + sealed interface State { + data class Success(val code: String) : State + + data class Error(val error: String? = null) : State + } +} \ No newline at end of file diff --git a/di/src/commonMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt b/di/src/commonMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt new file mode 100644 index 00000000..5a308db3 --- /dev/null +++ b/di/src/commonMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt @@ -0,0 +1,3 @@ +package social.androiddev.common.web + +expect class WebOpenExtras \ No newline at end of file diff --git a/di/src/desktopMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt b/di/src/desktopMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt new file mode 100644 index 00000000..eb1eac5c --- /dev/null +++ b/di/src/desktopMain/kotlin/social/androiddev/common/web/WebOpenExtras.kt @@ -0,0 +1,3 @@ +package social.androiddev.common.web + +actual class WebOpenExtras \ No newline at end of file diff --git a/domain/authentication/build.gradle.kts b/domain/authentication/build.gradle.kts index 79e02dbe..7d9c4d1d 100644 --- a/domain/authentication/build.gradle.kts +++ b/domain/authentication/build.gradle.kts @@ -22,5 +22,12 @@ kotlin { implementation(libs.org.jetbrains.kotlin.test.annotations.common) } } + + val desktopMain by getting { + dependencies { + implementation(libs.io.ktor.server.core) + implementation(libs.io.ktor.server.netty) + } + } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f38b54e..5d325bf6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,11 +47,14 @@ io-ktor-client-mock-jvm = { module = "io.ktor:ktor-client-mock-jvm", version.ref io-ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "io-ktor" } io-ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "io-ktor" } io-ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "io-ktor" } +io-ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "io-ktor" } +io-ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "io-ktor" } io-insert-koin-core = { module = "io.insert-koin:koin-core", version.ref = "io-insert-koin" } io-insert-koin-test = { module = "io.insert-koin:koin-test", version.ref = "io-insert-koin" } io-insert-koin-android = { module = "io.insert-koin:koin-android", version.ref = "io-insert-koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "org-jetbrains-kotlinx-coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "org-jetbrains-kotlinx-coroutines" } kotlinx-coroutines-javafx = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-javafx", version.ref = "org-jetbrains-kotlinx-coroutines" } org-jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "org-jetbrains-kotlinx-coroutines" } org-jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "org-jetbrains-kotlinx-serialization" } diff --git a/ui/signed-out/build.gradle.kts b/ui/signed-out/build.gradle.kts index efe1c11c..12d32e12 100644 --- a/ui/signed-out/build.gradle.kts +++ b/ui/signed-out/build.gradle.kts @@ -12,6 +12,7 @@ kotlin { val commonMain by getting { dependencies { implementation(projects.ui.common) + implementation(projects.di) implementation(projects.domain.authentication) implementation(libs.io.insert.koin.core) } diff --git a/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt b/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt deleted file mode 100644 index 91941043..00000000 --- a/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ -package social.androiddev.signedout.selectserver - -/** - * Note: this _must_ match the value in the manifest for deep linking back to the app to work - * correctly. - */ -actual val redirectScheme: String get() = "dodooauth2redirect" diff --git a/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt b/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt new file mode 100644 index 00000000..3acc4484 --- /dev/null +++ b/ui/signed-out/src/androidMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt @@ -0,0 +1,14 @@ +package social.androiddev.signedout.selectserver + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import social.androiddev.common.web.WebOpenExtras + +@Composable +actual fun webOpenExtras(): WebOpenExtras { + return WebOpenExtras( + primaryColor = MaterialTheme.colors.primary.toArgb(), + secondaryColor = MaterialTheme.colors.secondary.toArgb(), + ) +} \ No newline at end of file diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/root/DefaultSignedOutRootComponent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/root/DefaultSignedOutRootComponent.kt index 486408d0..8b975f3b 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/root/DefaultSignedOutRootComponent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/root/DefaultSignedOutRootComponent.kt @@ -15,6 +15,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.router.stack.replaceAll import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize @@ -78,9 +79,7 @@ class DefaultSignedOutRootComponent( ) = DefaultSelectServerComponent( componentContext = componentContext, mainContext = mainContext, - launchOAuth = { - navigation.push(Config.SignIn) - } + onAuthenticated = navigateToTimeLine, ) private fun createSignInComponent( diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/DefaultSelectServerComponent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/DefaultSelectServerComponent.kt index 2105b7cf..a14d8da5 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/DefaultSelectServerComponent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/DefaultSelectServerComponent.kt @@ -17,21 +17,33 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import social.androiddev.common.decompose.coroutineScope +import social.androiddev.common.web.WebAuth +import social.androiddev.common.web.WebOpenExtras +import social.androiddev.domain.authentication.model.ApplicationOAuthToken import social.androiddev.domain.authentication.usecase.AuthenticateClient +import social.androiddev.domain.authentication.usecase.CreateAccessToken +import social.androiddev.domain.authentication.usecase.GetSelectedApplicationOAuthToken +import social.androiddev.signedout.util.encode import kotlin.coroutines.CoroutineContext class DefaultSelectServerComponent( private val componentContext: ComponentContext, mainContext: CoroutineContext, - private val launchOAuth: () -> Unit, + onAuthenticated: () -> Unit, ) : KoinComponent, SelectServerComponent, ComponentContext by componentContext { private val authenticateClient: AuthenticateClient by inject() + private val getSelectedApplicationOAuthToken: GetSelectedApplicationOAuthToken by inject() + private val createAccessToken: CreateAccessToken by inject() + private val webAuth: WebAuth by inject() private val viewModel = instanceKeeper.getOrCreate { SelectServerViewModel( mainContext = mainContext, authenticateClient = authenticateClient, + getSelectedApplicationOAuthToken = getSelectedApplicationOAuthToken, + createAccessToken = createAccessToken, + webAuth = webAuth, ) } @@ -40,12 +52,24 @@ class DefaultSelectServerComponent( override val state: StateFlow = viewModel.state - override fun onServerSelected(server: String) { + init { scope.launch { - val success = viewModel.validateServer(server) - if (success) { - launchOAuth() + viewModel.authenticated.collect { authenticated -> + if (authenticated) { + onAuthenticated() + } } } } + + override fun onServerSelected(server: String, extras: WebOpenExtras) { + scope.launch { + viewModel.validateAndOpenServerAuth(server, extras) + } + } + + override fun onAuthCanceled() { + viewModel.cancelServerAuth() + } } + diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/RedirectScheme.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/RedirectScheme.kt deleted file mode 100644 index b64ac44d..00000000 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/RedirectScheme.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ -package social.androiddev.signedout.selectserver - -expect val redirectScheme: String diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerComponent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerComponent.kt index 5789585f..e471c0a1 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerComponent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerComponent.kt @@ -10,6 +10,7 @@ package social.androiddev.signedout.selectserver import kotlinx.coroutines.flow.StateFlow +import social.androiddev.common.web.WebOpenExtras /** * The base component describing all business logic needed for the select server screen @@ -18,9 +19,12 @@ interface SelectServerComponent { val state: StateFlow - fun onServerSelected(server: String) + fun onServerSelected(server: String, extras: WebOpenExtras) + fun onAuthCanceled() data class State( - val selectButtonEnabled: Boolean = true, + val server: String = "", + val loading: Boolean = false, + val error: String? = null, ) } diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt index 67849f19..4e425877 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt @@ -9,11 +9,23 @@ */ package social.androiddev.signedout.selectserver +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.Spacer 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.wrapContentHeight +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,6 +38,7 @@ import androidx.compose.ui.unit.dp import social.androiddev.common.composables.buttons.DodoButton import social.androiddev.common.composables.text.DodoTextField import social.androiddev.common.theme.DodoTheme +import social.androiddev.common.web.WebOpenExtras /** * Select Server view that delegates business/navigation logic to [SelectServerComponent] @@ -37,19 +50,26 @@ fun SelectServerContent( component: SelectServerComponent, ) { val state by component.state.collectAsState() + val extras = webOpenExtras() SelectServerContent( modifier = modifier, - btnEnabled = state.selectButtonEnabled, - onServerSelected = component::onServerSelected, + loading = state.loading, + error = state.error, + onServerSelected = { + component.onServerSelected(it, extras) + }, + onCancel = component::onAuthCanceled ) } @Composable fun SelectServerContent( modifier: Modifier, - btnEnabled: Boolean, + loading: Boolean, + error: String?, onServerSelected: (String) -> Unit, + onCancel: () -> Unit, ) { var server by rememberSaveable { mutableStateOf("androiddev.social") } @@ -59,6 +79,10 @@ fun SelectServerContent( horizontalAlignment = Alignment.CenterHorizontally, ) { + AnimatedVisibility(error != null) { + ErrorBox(error.orEmpty()) + } + DodoTextField( value = server, onValueChange = { v -> server = v }, @@ -66,22 +90,60 @@ fun SelectServerContent( Spacer(Modifier.height(24.dp)) - DodoButton( - text = "Select", - enabled = btnEnabled, - onClick = { onServerSelected(server) }, + if (loading) { + Row { + CircularProgressIndicator() + DodoButton(text = "Cancel", onClick = onCancel) + } + } else { + DodoButton( + text = "Select", + onClick = { onServerSelected(server) }, + ) + } + + } +} + +@Composable +fun ErrorBox(message: String) { + Box( + modifier = Modifier.fillMaxWidth() + .wrapContentHeight() + .padding(16.dp) + .background(color = MaterialTheme.colors.error.copy(alpha = 0.5F)) + .border( + border = BorderStroke( + width = 2.dp, + color = + MaterialTheme.colors.error + ), + shape = MaterialTheme.shapes.small + ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + message, + color = MaterialTheme.colors.onError, + style = MaterialTheme.typography.subtitle2 ) } } +@Composable +expect fun webOpenExtras(): WebOpenExtras + // @Preview @Composable private fun PreviewLandingContent() { DodoTheme(true) { SelectServerContent( modifier = Modifier.fillMaxSize(), - btnEnabled = true, + loading = false, + error = null, onServerSelected = {}, + onCancel = {}, ) } -} +} \ No newline at end of file diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerViewModel.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerViewModel.kt index 90bf9804..931ea4a5 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerViewModel.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerViewModel.kt @@ -12,17 +12,28 @@ package social.androiddev.signedout.selectserver import com.arkivanov.essenty.instancekeeper.InstanceKeeper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import social.androiddev.common.web.WebAuth +import social.androiddev.common.web.WebOpenExtras +import social.androiddev.domain.authentication.model.ApplicationOAuthToken import social.androiddev.domain.authentication.usecase.AuthenticateClient +import social.androiddev.domain.authentication.usecase.CreateAccessToken +import social.androiddev.domain.authentication.usecase.GetSelectedApplicationOAuthToken +import social.androiddev.signedout.util.encode import kotlin.coroutines.CoroutineContext internal class SelectServerViewModel( mainContext: CoroutineContext, - private val authenticateClient: AuthenticateClient + private val authenticateClient: AuthenticateClient, + private val getSelectedApplicationOAuthToken: GetSelectedApplicationOAuthToken, + private val createAccessToken: CreateAccessToken, + private val webAuth: WebAuth, ) : InstanceKeeper.Instance { // The scope survives Android configuration changes @@ -31,21 +42,76 @@ internal class SelectServerViewModel( private val _state = MutableStateFlow(SelectServerComponent.State()) val state: StateFlow = _state.asStateFlow() - suspend fun validateServer(server: String): Boolean { + private val _authenticated = MutableStateFlow(false) + val authenticated: StateFlow = _authenticated + private val redirectUri = scope.async { webAuth.start() } + + init { + scope.launch { + webAuth.state.collect { state -> + when (state) { + is WebAuth.State.Success -> { + val success = createAccessToken( + authCode = state.code, + server = _state.value.server + ) + if (success) { + _authenticated.value = true + } else { + _state.update { + it.copy( + loading = false, + error = "An error occurred." + ) + } + } + } + + is WebAuth.State.Error -> { + _state.update { + it.copy( + loading = false, + error = state.error ?: "Something is Wrong!" + ) + } + } + } + } + } + } + + suspend fun validateAndOpenServerAuth(server: String, extras: WebOpenExtras) { // TODO Sanitize and format the user entered server - _state.update { it.copy(selectButtonEnabled = false) } + _state.update { + it.copy( + server = server, + loading = true, + error = null + ) + } val success = authenticateClient( domain = server, clientName = "Dodo", - redirectURIs = "$redirectScheme://$server/", + redirectURIs = redirectUri.await(), scopes = OAUTH_SCOPES, website = "https://$server", ) - _state.update { it.copy(selectButtonEnabled = true) } - return success + + if (success) { + val token = getSelectedApplicationOAuthToken() + webAuth.open(createOAuthAuthorizeUrl(token), extras) + } else { + _state.update { it.copy(loading = false) } + } + } + + fun cancelServerAuth() { + _state.update { + it.copy(loading = false) + } } override fun onDestroy() { @@ -56,3 +122,13 @@ internal class SelectServerViewModel( private const val OAUTH_SCOPES = "read write follow push" } } + +private fun createOAuthAuthorizeUrl(token: ApplicationOAuthToken): String { + return buildString { + append("https://${token.server}") + append("/oauth/authorize?client_id=${token.clientId}") + append("&scope=${"read write follow push".encode()}") + append("&redirect_uri=${token.redirectUri.encode()}") + append("&response_type=code") + } +} diff --git a/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt b/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt deleted file mode 100644 index 2219590d..00000000 --- a/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/redirectScheme.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ -package social.androiddev.signedout.selectserver - -actual val redirectScheme: String get() = "https" diff --git a/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt b/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt new file mode 100644 index 00000000..aa85f290 --- /dev/null +++ b/ui/signed-out/src/desktopMain/kotlin/social/androiddev/signedout/selectserver/webOpenExtras.kt @@ -0,0 +1,9 @@ +package social.androiddev.signedout.selectserver + +import androidx.compose.runtime.Composable +import social.androiddev.common.web.WebOpenExtras + +@Composable +actual fun webOpenExtras(): WebOpenExtras { + return WebOpenExtras() +} \ No newline at end of file