From b774d5604cbd4e79ab0aa68cd05ecf5d636c6ce0 Mon Sep 17 00:00:00 2001 From: Bogdan Carpusor Date: Tue, 21 Apr 2026 21:36:09 +0300 Subject: [PATCH 1/2] feat: Add the SuperTokens lazy migration flow --- .../src/main/java/io/rownd/android/Rownd.kt | 19 +++++++++++++ .../io/rownd/android/models/RowndConfig.kt | 15 ++++++++++ .../io/rownd/android/util/SuperTokensSync.kt | 28 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 android/src/main/java/io/rownd/android/util/SuperTokensSync.kt diff --git a/android/src/main/java/io/rownd/android/Rownd.kt b/android/src/main/java/io/rownd/android/Rownd.kt index b8b381a..33903ae 100644 --- a/android/src/main/java/io/rownd/android/Rownd.kt +++ b/android/src/main/java/io/rownd/android/Rownd.kt @@ -38,7 +38,9 @@ import io.rownd.android.util.InvalidRefreshTokenException import io.rownd.android.util.NoAccessTokenPresentException import io.rownd.android.util.NoRefreshTokenPresentException import io.rownd.android.util.RowndEvent +import io.rownd.android.util.RowndEventType import io.rownd.android.util.RowndException +import io.rownd.android.util.syncUserToSuperTokens import io.rownd.android.views.HubPageSelector import io.rownd.android.views.RowndBottomSheetActivity import io.rownd.android.views.RowndWebViewModel @@ -89,6 +91,23 @@ class RowndClient( rowndContext.eventEmitter = eventEmitter rowndContext.telemetry = telemetry + eventEmitter.addListener { event -> + if (event.event != RowndEventType.SignInCompleted || event.data["user_type"] != "new_user") { + return@addListener + } + + if (!this::store.isInitialized) { + return@addListener + } + + val accessToken = store.currentState.auth.accessToken ?: return@addListener + val appInfo = config.supertokens?.appInfo ?: return@addListener + + CoroutineScope(Dispatchers.IO).launch { + syncUserToSuperTokens(accessToken = accessToken, appInfo = appInfo) + } + } + stateRepo.userRepo = userRepo stateRepo.authRepo = authRepo } diff --git a/android/src/main/java/io/rownd/android/models/RowndConfig.kt b/android/src/main/java/io/rownd/android/models/RowndConfig.kt index e9484a2..3d25102 100644 --- a/android/src/main/java/io/rownd/android/models/RowndConfig.kt +++ b/android/src/main/java/io/rownd/android/models/RowndConfig.kt @@ -15,6 +15,18 @@ import javax.inject.Inject val json = Json { encodeDefaults = true } +@Serializable +data class SuperTokensAppInfo( + val appName: String, + val apiDomain: String, + val apiBasePath: String = "/auth" +) + +@Serializable +data class SuperTokensConfig( + val appInfo: SuperTokensAppInfo +) + @Serializable data class RowndConfig( var appKey: String? = null, @@ -34,6 +46,9 @@ data class RowndConfig( @Transient var enableSmartLinkPasteBehavior: Boolean = true, + @Transient + var supertokens: SuperTokensConfig? = null, + // Internals @Transient internal var stateFileName: String = "rownd_state.json" diff --git a/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt b/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt new file mode 100644 index 0000000..c754aff --- /dev/null +++ b/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt @@ -0,0 +1,28 @@ +package io.rownd.android.util + +import android.util.Log +import io.rownd.android.models.SuperTokensAppInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +suspend fun syncUserToSuperTokens( + accessToken: String, + appInfo: SuperTokensAppInfo, +) = withContext(Dispatchers.IO) { + val base = "${appInfo.apiDomain}${appInfo.apiBasePath}" + + try { + val conn = URL("$base/plugin/rownd/migrate").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Authorization", "Bearer $accessToken") + val code = conn.responseCode + if (code !in 200..299) { + Log.e("Rownd.ST", "[Rownd->ST] migrate failed with status: $code") + } + conn.disconnect() + } catch (e: Exception) { + Log.e("Rownd.ST", "[Rownd->ST] migrate failed (non-fatal): ${e.message}") + } +} \ No newline at end of file From 4473272582831bff05b06242299bc31936d0529f Mon Sep 17 00:00:00 2001 From: Bogdan Carpusor Date: Wed, 22 Apr 2026 20:45:21 +0300 Subject: [PATCH 2/2] fix: Code review fixes --- .../src/main/java/io/rownd/android/Rownd.kt | 6 +-- .../android/di/component/RowndComponent.kt | 4 +- .../io/rownd/android/models/RowndConfig.kt | 15 +++++++- .../io/rownd/android/util/SuperTokensSync.kt | 37 +++++++++---------- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/io/rownd/android/Rownd.kt b/android/src/main/java/io/rownd/android/Rownd.kt index 33903ae..2848d46 100644 --- a/android/src/main/java/io/rownd/android/Rownd.kt +++ b/android/src/main/java/io/rownd/android/Rownd.kt @@ -40,7 +40,6 @@ import io.rownd.android.util.NoRefreshTokenPresentException import io.rownd.android.util.RowndEvent import io.rownd.android.util.RowndEventType import io.rownd.android.util.RowndException -import io.rownd.android.util.syncUserToSuperTokens import io.rownd.android.views.HubPageSelector import io.rownd.android.views.RowndBottomSheetActivity import io.rownd.android.views.RowndWebViewModel @@ -78,6 +77,7 @@ class RowndClient( internal var eventEmitter = graph.rowndEventEmitter() internal var signInWithGoogle = graph.signInWithGoogle() internal var telemetry = graph.telemetry() + internal var superTokensSync = graph.superTokensSync() var state = stateRepo.state var user = userRepo @@ -104,7 +104,7 @@ class RowndClient( val appInfo = config.supertokens?.appInfo ?: return@addListener CoroutineScope(Dispatchers.IO).launch { - syncUserToSuperTokens(accessToken = accessToken, appInfo = appInfo) + superTokensSync.syncUser(accessToken = accessToken, appInfo = appInfo) } } @@ -582,4 +582,4 @@ enum class RowndSignInLoginStep { enum class RowndSignOutScope { all -} \ No newline at end of file +} diff --git a/android/src/main/java/io/rownd/android/di/component/RowndComponent.kt b/android/src/main/java/io/rownd/android/di/component/RowndComponent.kt index a67e507..8ae2f16 100644 --- a/android/src/main/java/io/rownd/android/di/component/RowndComponent.kt +++ b/android/src/main/java/io/rownd/android/di/component/RowndComponent.kt @@ -19,6 +19,7 @@ import io.rownd.android.util.RowndContext import io.rownd.android.util.RowndEvent import io.rownd.android.util.RowndEventEmitter import io.rownd.android.util.SignInWithGoogle +import io.rownd.android.util.SuperTokensSync import io.rownd.android.util.Telemetry import io.rownd.android.util.TokenApiClient import javax.inject.Singleton @@ -44,9 +45,10 @@ interface RowndGraph { fun rowndEventEmitter(): RowndEventEmitter fun signInWithGoogle(): SignInWithGoogle fun telemetry(): Telemetry + fun superTokensSync(): SuperTokensSync fun tokenApiClient(): TokenApiClient fun authenticatedApiClient(): AuthenticatedApiClient fun httpEngine(): HttpClientEngine fun config(): RowndConfig fun inject(rowndConfig: RowndConfig) -} \ No newline at end of file +} diff --git a/android/src/main/java/io/rownd/android/models/RowndConfig.kt b/android/src/main/java/io/rownd/android/models/RowndConfig.kt index 3d25102..f9b7eca 100644 --- a/android/src/main/java/io/rownd/android/models/RowndConfig.kt +++ b/android/src/main/java/io/rownd/android/models/RowndConfig.kt @@ -20,7 +20,20 @@ data class SuperTokensAppInfo( val appName: String, val apiDomain: String, val apiBasePath: String = "/auth" -) +) { + val normalizedApiDomain: String + get() = apiDomain.trimEnd('/') + + val normalizedApiBasePath: String + get() { + val basePath = apiBasePath.trim().trim('/') + return if (basePath.isEmpty()) "" else "/$basePath" + } + + fun migrationUrl(): String { + return "${normalizedApiDomain}${normalizedApiBasePath}/plugin/rownd/migrate" + } +} @Serializable data class SuperTokensConfig( diff --git a/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt b/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt index c754aff..0ab4f9a 100644 --- a/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt +++ b/android/src/main/java/io/rownd/android/util/SuperTokensSync.kt @@ -1,28 +1,25 @@ package io.rownd.android.util import android.util.Log +import io.ktor.client.request.header +import io.ktor.client.request.post import io.rownd.android.models.SuperTokensAppInfo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.HttpURLConnection -import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton -suspend fun syncUserToSuperTokens( - accessToken: String, - appInfo: SuperTokensAppInfo, -) = withContext(Dispatchers.IO) { - val base = "${appInfo.apiDomain}${appInfo.apiBasePath}" +@Singleton +class SuperTokensSync @Inject constructor( + private val apiClient: KtorApiClient, +) { + suspend fun syncUser(accessToken: String, appInfo: SuperTokensAppInfo) { + val migrationUrl = appInfo.migrationUrl() - try { - val conn = URL("$base/plugin/rownd/migrate").openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.setRequestProperty("Authorization", "Bearer $accessToken") - val code = conn.responseCode - if (code !in 200..299) { - Log.e("Rownd.ST", "[Rownd->ST] migrate failed with status: $code") + try { + apiClient.client.post(migrationUrl) { + header("Authorization", "Bearer $accessToken") + } + } catch (e: Exception) { + Log.e("Rownd.ST", "[Rownd->ST] migrate failed (non-fatal): ${e.message}") } - conn.disconnect() - } catch (e: Exception) { - Log.e("Rownd.ST", "[Rownd->ST] migrate failed (non-fatal): ${e.message}") } -} \ No newline at end of file +}