From 5916ab5d7c51ede7550701673f79db777b7f7c70 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Sat, 2 May 2026 14:40:59 -0400 Subject: [PATCH 1/3] Users can sign in with oauth --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 10 ++ .../java/org/rubyevents/app/MainActivity.kt | 16 +++ .../org/rubyevents/app/MainApplication.kt | 6 +- .../main/java/org/rubyevents/app/Router.kt | 3 + .../java/org/rubyevents/app/hotwire/Tabs.kt | 13 +- .../app/hotwire/bridge/OAuthComponent.kt | 123 ++++++++++++++++++ .../hotwire/fragments/RefreshAppFragment.kt | 16 +++ app/src/main/res/drawable/ic_tab_profile.xml | 13 ++ app/src/main/res/layout/activity_main.xml | 9 ++ gradle/libs.versions.toml | 2 + 11 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt create mode 100644 app/src/main/java/org/rubyevents/app/hotwire/fragments/RefreshAppFragment.kt create mode 100644 app/src/main/res/drawable/ic_tab_profile.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7715078..6274229 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.browser) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material3) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ecb324..413b775 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,12 +20,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/rubyevents/app/MainActivity.kt b/app/src/main/java/org/rubyevents/app/MainActivity.kt index f036461..da69377 100644 --- a/app/src/main/java/org/rubyevents/app/MainActivity.kt +++ b/app/src/main/java/org/rubyevents/app/MainActivity.kt @@ -1,5 +1,6 @@ package org.rubyevents.app +import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.enableEdgeToEdge @@ -9,6 +10,7 @@ import dev.hotwire.navigation.activities.HotwireActivity import dev.hotwire.navigation.tabs.HotwireBottomNavigationController import dev.hotwire.navigation.util.applyDefaultImeWindowInsets import dev.hotwire.navigation.tabs.navigatorConfigurations +import org.rubyevents.app.hotwire.bridge.OAuthComponent import org.rubyevents.app.hotwire.tabs import org.rubyevents.app.hotwire.viewmodels.MainActivityViewModel @@ -22,6 +24,20 @@ class MainActivity : HotwireActivity() { setContentView(R.layout.activity_main) findViewById(R.id.root).applyDefaultImeWindowInsets() initializeBottomTabs() + handleOAuthRedirect(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleOAuthRedirect(intent) + } + + private fun handleOAuthRedirect(intent: Intent?) { + if (intent?.action != Intent.ACTION_VIEW) return + val uri = intent.data ?: return + if (uri.scheme != "rubyevents" || uri.host != "auth") return + OAuthComponent.handleRedirect(uri) } private fun initializeBottomTabs() { diff --git a/app/src/main/java/org/rubyevents/app/MainApplication.kt b/app/src/main/java/org/rubyevents/app/MainApplication.kt index e3ca9be..31491a7 100644 --- a/app/src/main/java/org/rubyevents/app/MainApplication.kt +++ b/app/src/main/java/org/rubyevents/app/MainApplication.kt @@ -12,6 +12,8 @@ import dev.hotwire.navigation.config.registerBridgeComponents import dev.hotwire.navigation.config.registerFragmentDestinations import dev.hotwire.navigation.fragments.HotwireWebBottomSheetFragment import org.rubyevents.app.hotwire.bridge.ButtonComponent +import org.rubyevents.app.hotwire.bridge.OAuthComponent +import org.rubyevents.app.hotwire.fragments.RefreshAppFragment import org.rubyevents.app.hotwire.fragments.WebFragment import org.rubyevents.app.hotwire.CustomWebView @@ -32,6 +34,7 @@ class MainApplication : Application() { Hotwire.registerFragmentDestinations( WebFragment::class, HotwireWebBottomSheetFragment::class, + RefreshAppFragment::class, ) // PathConfiguration @@ -44,7 +47,8 @@ class MainApplication : Application() { // Bridge components Hotwire.registerBridgeComponents( - BridgeComponentFactory("button", ::ButtonComponent) + BridgeComponentFactory("button", ::ButtonComponent), + BridgeComponentFactory("oauth", ::OAuthComponent) ) // Custom WebView diff --git a/app/src/main/java/org/rubyevents/app/Router.kt b/app/src/main/java/org/rubyevents/app/Router.kt index 3f1d971..c41c745 100644 --- a/app/src/main/java/org/rubyevents/app/Router.kt +++ b/app/src/main/java/org/rubyevents/app/Router.kt @@ -28,4 +28,7 @@ object Router { val speakersURL: String get() { return "$baseURL/speakers" } + + val signInURL: String + get() { return "$baseURL/hotwire/native/v1/oauth" } } diff --git a/app/src/main/java/org/rubyevents/app/hotwire/Tabs.kt b/app/src/main/java/org/rubyevents/app/hotwire/Tabs.kt index 627879d..a521488 100644 --- a/app/src/main/java/org/rubyevents/app/hotwire/Tabs.kt +++ b/app/src/main/java/org/rubyevents/app/hotwire/Tabs.kt @@ -45,9 +45,20 @@ private val speakers = HotwireBottomTab( ) ) +private val signIn = HotwireBottomTab( + title = "Sign In", + iconResId = R.drawable.ic_tab_profile, + configuration = NavigatorConfiguration( + name = "sign_in", + navigatorHostId = R.id.settings_navigator_host, + startLocation = Router.signInURL + ) +) + val tabs = listOf( home, events, talks, - speakers + speakers, + signIn ) diff --git a/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt new file mode 100644 index 0000000..f7faaad --- /dev/null +++ b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt @@ -0,0 +1,123 @@ +package org.rubyevents.app.hotwire.bridge + +import android.net.Uri +import android.util.Log +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dev.hotwire.core.bridge.BridgeComponent +import dev.hotwire.core.bridge.BridgeDelegate +import dev.hotwire.core.bridge.Message +import dev.hotwire.navigation.destinations.HotwireDestination +import dev.hotwire.navigation.fragments.HotwireFragment +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.rubyevents.app.R +import org.rubyevents.app.Router + +class OAuthComponent( + name: String, + private val bridgeDelegate: BridgeDelegate +) : BridgeComponent(name, bridgeDelegate) { + + private val fragment: HotwireFragment + get() = bridgeDelegate.destination.fragment as HotwireFragment + + private var pendingSignIn: Message? = null + private var collectorJob: Job? = null + + override fun onReceive(message: Message) { + Log.d(TAG, "onReceive event=${message.event} jsonData=${message.jsonData}") + when (message.event) { + "signIn" -> handleSignIn(message) + "disconnect" -> handleDisconnect() + else -> Log.w(TAG, "Unknown event: ${message.event}") + } + } + + private fun handleSignIn(message: Message) { + val data = message.data() + val path = data?.authorizationPath ?: return + val url = resolveAgainstBaseUrl(path) + Log.d(TAG, "handleSignIn url=$url") + pendingSignIn = message + clearCallbacks() + ensureCollector() + launchCustomTab(url) + } + + private fun resolveAgainstBaseUrl(path: String): String { + return Router.startURL.trimEnd('/') + (if (path.startsWith("/")) path else "/$path") + } + + private fun handleDisconnect() { + pendingSignIn = null + collectorJob?.cancel() + collectorJob = null + } + + private fun launchCustomTab(url: String) { + val context = fragment.requireContext() + val brandColor = ContextCompat.getColor(context, R.color.main_app_color) + CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(brandColor) + .build() + ) + .setShowTitle(true) + .build() + .launchUrl(context, Uri.parse(url)) + } + + private fun ensureCollector() { + if (collectorJob?.isActive == true) return + collectorJob = fragment.viewLifecycleOwner.lifecycleScope.launch { + fragment.viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + callbacks.collectLatest { onRedirect(it) } + } + } + } + + private fun onRedirect(uri: Uri) { + val message = pendingSignIn ?: return + pendingSignIn = null + clearCallbacks() + val token = uri.getQueryParameter("token") + replyWith(message.replacing(event = "success", data = SuccessData(token = token))) + Log.d(TAG, "Replied success for $uri (originalEvent=${message.event})") + } + + @Serializable + data class MessageData(val authorizationPath: String? = null) + + @Serializable + data class SuccessData(val token: String? = null) + + companion object { + private const val TAG = "OAuthComponent" + + private val callbacksFlow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val callbacks: SharedFlow = callbacksFlow + + fun handleRedirect(uri: Uri) { + callbacksFlow.tryEmit(uri) + } + + private fun clearCallbacks() { + callbacksFlow.resetReplayCache() + } + } +} diff --git a/app/src/main/java/org/rubyevents/app/hotwire/fragments/RefreshAppFragment.kt b/app/src/main/java/org/rubyevents/app/hotwire/fragments/RefreshAppFragment.kt new file mode 100644 index 0000000..87e96a6 --- /dev/null +++ b/app/src/main/java/org/rubyevents/app/hotwire/fragments/RefreshAppFragment.kt @@ -0,0 +1,16 @@ +package org.rubyevents.app.hotwire.fragments + +import android.os.Bundle +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireFragment +import org.rubyevents.app.MainActivity + +@HotwireDestinationDeepLink(uri = "hotwire://fragment/refresh_app") +class RefreshAppFragment : HotwireFragment() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + navigator.reset() + (activity as? MainActivity)?.delegate?.resetNavigators() + } +} diff --git a/app/src/main/res/drawable/ic_tab_profile.xml b/app/src/main/res/drawable/ic_tab_profile.xml new file mode 100644 index 0000000..48f48c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_profile.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b7e82af..2478ca9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -44,6 +44,15 @@ app:layout_constraintBottom_toTopOf="@id/bottom_nav" app:defaultNavHost="false" /> + + Date: Sat, 2 May 2026 21:22:12 -0400 Subject: [PATCH 2/3] Simplify oauth component --- .../app/hotwire/bridge/OAuthComponent.kt | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt index f7faaad..78050a6 100644 --- a/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt +++ b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt @@ -13,11 +13,9 @@ import dev.hotwire.core.bridge.BridgeDelegate import dev.hotwire.core.bridge.Message import dev.hotwire.navigation.destinations.HotwireDestination import dev.hotwire.navigation.fragments.HotwireFragment -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.rubyevents.app.R @@ -32,25 +30,30 @@ class OAuthComponent( get() = bridgeDelegate.destination.fragment as HotwireFragment private var pendingSignIn: Message? = null - private var collectorJob: Job? = null override fun onReceive(message: Message) { Log.d(TAG, "onReceive event=${message.event} jsonData=${message.jsonData}") when (message.event) { "signIn" -> handleSignIn(message) - "disconnect" -> handleDisconnect() + "disconnect" -> { pendingSignIn = null } else -> Log.w(TAG, "Unknown event: ${message.event}") } } + override fun onStart() { + fragment.viewLifecycleOwner.lifecycleScope.launch { + fragment.viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + callbacks.collect { onRedirect(it) } + } + } + } + private fun handleSignIn(message: Message) { - val data = message.data() - val path = data?.authorizationPath ?: return + val path = message.data()?.authorizationPath ?: return val url = resolveAgainstBaseUrl(path) Log.d(TAG, "handleSignIn url=$url") pendingSignIn = message clearCallbacks() - ensureCollector() launchCustomTab(url) } @@ -58,12 +61,6 @@ class OAuthComponent( return Router.startURL.trimEnd('/') + (if (path.startsWith("/")) path else "/$path") } - private fun handleDisconnect() { - pendingSignIn = null - collectorJob?.cancel() - collectorJob = null - } - private fun launchCustomTab(url: String) { val context = fragment.requireContext() val brandColor = ContextCompat.getColor(context, R.color.main_app_color) @@ -78,15 +75,6 @@ class OAuthComponent( .launchUrl(context, Uri.parse(url)) } - private fun ensureCollector() { - if (collectorJob?.isActive == true) return - collectorJob = fragment.viewLifecycleOwner.lifecycleScope.launch { - fragment.viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - callbacks.collectLatest { onRedirect(it) } - } - } - } - private fun onRedirect(uri: Uri) { val message = pendingSignIn ?: return pendingSignIn = null From 9e5c20241c390e7564711c5a368b7e5470abdb74 Mon Sep 17 00:00:00 2001 From: Mike Dalton Date: Mon, 4 May 2026 18:28:24 -0400 Subject: [PATCH 3/3] Rename authorizationPath to startPath --- .../java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt index 78050a6..6aa7eee 100644 --- a/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt +++ b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt @@ -49,7 +49,7 @@ class OAuthComponent( } private fun handleSignIn(message: Message) { - val path = message.data()?.authorizationPath ?: return + val path = message.data()?.startPath ?: return val url = resolveAgainstBaseUrl(path) Log.d(TAG, "handleSignIn url=$url") pendingSignIn = message @@ -85,7 +85,7 @@ class OAuthComponent( } @Serializable - data class MessageData(val authorizationPath: String? = null) + data class MessageData(val startPath: String? = null) @Serializable data class SuccessData(val token: String? = null)