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" /> + +