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..6aa7eee
--- /dev/null
+++ b/app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt
@@ -0,0 +1,111 @@
+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.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+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
+
+ override fun onReceive(message: Message) {
+ Log.d(TAG, "onReceive event=${message.event} jsonData=${message.jsonData}")
+ when (message.event) {
+ "signIn" -> handleSignIn(message)
+ "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 path = message.data()?.startPath ?: return
+ val url = resolveAgainstBaseUrl(path)
+ Log.d(TAG, "handleSignIn url=$url")
+ pendingSignIn = message
+ clearCallbacks()
+ launchCustomTab(url)
+ }
+
+ private fun resolveAgainstBaseUrl(path: String): String {
+ return Router.startURL.trimEnd('/') + (if (path.startsWith("/")) path else "/$path")
+ }
+
+ 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 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 startPath: 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" />
+
+