Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,22 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="rubyevents"
android:host="auth" />
</intent-filter>
</activity>
</application>
</manifest>
16 changes: 16 additions & 0 deletions app/src/main/java/org/rubyevents/app/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.rubyevents.app

import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.enableEdgeToEdge
Expand All @@ -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

Expand All @@ -22,6 +24,20 @@ class MainActivity : HotwireActivity() {
setContentView(R.layout.activity_main)
findViewById<View>(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() {
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/org/rubyevents/app/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,6 +34,7 @@ class MainApplication : Application() {
Hotwire.registerFragmentDestinations(
WebFragment::class,
HotwireWebBottomSheetFragment::class,
RefreshAppFragment::class,
)

// PathConfiguration
Expand All @@ -44,7 +47,8 @@ class MainApplication : Application() {

// Bridge components
Hotwire.registerBridgeComponents(
BridgeComponentFactory("button", ::ButtonComponent)
BridgeComponentFactory("button", ::ButtonComponent),
BridgeComponentFactory("oauth", ::OAuthComponent)
)

// Custom WebView
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/org/rubyevents/app/Router.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ object Router {

val speakersURL: String
get() { return "$baseURL/speakers" }

val signInURL: String
get() { return "$baseURL/hotwire/native/v1/oauth" }
}
13 changes: 12 additions & 1 deletion app/src/main/java/org/rubyevents/app/hotwire/Tabs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
111 changes: 111 additions & 0 deletions app/src/main/java/org/rubyevents/app/hotwire/bridge/OAuthComponent.kt
Original file line number Diff line number Diff line change
@@ -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<HotwireDestination>
) : BridgeComponent<HotwireDestination>(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<MessageData>()?.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<Uri>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val callbacks: SharedFlow<Uri> = callbacksFlow

fun handleRedirect(uri: Uri) {
callbacksFlow.tryEmit(uri)
}

private fun clearCallbacks() {
callbacksFlow.resetReplayCache()
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
13 changes: 13 additions & 0 deletions app/src/main/res/drawable/ic_tab_profile.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
android:viewportWidth="960"
android:viewportHeight="960"
android:width="24dp"
android:height="24dp">
<group
android:translateX="-0"
android:translateY="960">
<path
android:pathData="M480 -480q-66 0 -113 -47t-47 -113q0 -66 47 -113t113 -47q66 0 113 47t47 113q0 66 -47 113t-113 47ZM160 -160v-112q0 -34 17.5 -62.5T224 -378q62 -31 126 -46.5T480 -440q66 0 130 15.5T736 -378q29 14 46.5 42.5T800 -272v112H160Zm80 -80h480v-32q0 -11 -5.5 -20T700 -306q-54 -27 -109 -40.5T480 -360q-56 0 -111 13.5T260 -306q-9 5 -14.5 14t-5.5 20v32Zm240 -320q33 0 56.5 -23.5T560 -640q0 -33 -23.5 -56.5T480 -720q-33 0 -56.5 23.5T400 -640q0 33 23.5 56.5T480 -560Zm0 -80Zm0 400Z"
android:fillColor="#E3E3E3" />
</group>
</vector>
9 changes: 9 additions & 0 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
app:defaultNavHost="false" />

<androidx.fragment.app.FragmentContainerView
android:id="@+id/settings_navigator_host"
android:name="dev.hotwire.navigation.navigator.NavigatorHost"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
app:defaultNavHost="false" />

<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ navigationFragments = "1.2.0"
kotlinxSerializationJson = "1.8.1"
composeBom = "2025.03.00"
material3 = "1.3.2"
browser = "1.8.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
Expand All @@ -29,6 +30,7 @@ navigation-fragments = { group = "dev.hotwire", name = "navigation-fragments", v
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down