diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/ApiProvider.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/api/ApiProvider.kt index 9d0782a0..ecca18e2 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/ApiProvider.kt +++ b/app/common/src/commonMain/kotlin/sh/christian/ozone/api/ApiProvider.kt @@ -9,15 +9,12 @@ import io.ktor.client.plugins.logging.Logging import io.ktor.http.takeFrom import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.yield import me.tatarka.inject.annotations.Inject import sh.christian.ozone.BlueskyApi -import sh.christian.ozone.XrpcBlueskyApi import sh.christian.ozone.app.Supervisor import sh.christian.ozone.di.SingleInApp import sh.christian.ozone.login.LoginRepository @@ -29,10 +26,7 @@ class ApiProvider( private val apiRepository: ServerRepository, private val loginRepository: LoginRepository, ) : Supervisor() { - private val apiHost = MutableStateFlow(apiRepository.server.host) - private val auth = MutableStateFlow(loginRepository.auth) - private val tokens = MutableStateFlow(loginRepository.auth?.toTokens()) private val client = HttpClient(engine) { install(Logging) { @@ -40,10 +34,6 @@ class ApiProvider( level = LogLevel.NONE } - install(XrpcAuthPlugin) { - authTokens = tokens - } - install(DefaultRequest) { url.takeFrom(apiHost.value) } @@ -51,7 +41,12 @@ class ApiProvider( expectSuccess = false } - val api: BlueskyApi = XrpcBlueskyApi(client) + private val _api = AuthenticatedXrpcBlueskyApi( + httpClient = client, + initialTokens = loginRepository.auth?.toTokens(), + ) + + val api: BlueskyApi = _api override suspend fun CoroutineScope.onStart() { coroutineScope { @@ -62,15 +57,7 @@ class ApiProvider( } launch(OzoneDispatchers.IO) { - loginRepository.authFlow().collect { - tokens.value = it?.toTokens() - yield() - auth.value = it - } - } - - launch(OzoneDispatchers.IO) { - tokens.collect { tokens -> + _api.authTokens.collect { tokens -> if (tokens != null) { loginRepository.auth = loginRepository.auth?.withTokens(tokens) } else { @@ -81,11 +68,13 @@ class ApiProvider( } } - fun auth(): Flow = auth + fun signOut() { + _api.clearCredentials() + } - private fun AuthInfo.toTokens() = Tokens(accessJwt, refreshJwt) + private fun AuthInfo.toTokens() = BlueskyAuthPlugin.Tokens(accessJwt, refreshJwt) - private fun AuthInfo.withTokens(tokens: Tokens) = copy( + private fun AuthInfo.withTokens(tokens: BlueskyAuthPlugin.Tokens) = copy( accessJwt = tokens.auth, refreshJwt = tokens.refresh, ) diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/Tokens.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/api/Tokens.kt deleted file mode 100644 index a68c05c9..00000000 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/Tokens.kt +++ /dev/null @@ -1,6 +0,0 @@ -package sh.christian.ozone.api - -data class Tokens( - val auth: String, - val refresh: String, -) diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/app/AppWorkflow.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/app/AppWorkflow.kt index 280e9418..089720ec 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/app/AppWorkflow.kt +++ b/app/common/src/commonMain/kotlin/sh/christian/ozone/app/AppWorkflow.kt @@ -47,7 +47,7 @@ class AppWorkflow( context: RenderContext, ): AppScreen = when (renderState) { is ShowingLogin -> { - context.runningWorker(apiProvider.auth().filterNotNull().asWorker(), "has-auth") { auth -> + context.runningWorker(loginRepository.authFlow().filterNotNull().asWorker(), "has-auth") { auth -> action { state = ShowingLoggedIn(HomeProps(auth, 0)) } @@ -68,7 +68,7 @@ class AppWorkflow( state = ShowingLoggedIn(renderState.props.copy(unreadNotificationCount = unread)) } } - context.runningWorker(apiProvider.auth().filter { it == null }.asWorker(), "no-auth") { + context.runningWorker(loginRepository.authFlow().filter { it == null }.asWorker(), "no-auth") { action { state = ShowingLogin } @@ -78,7 +78,7 @@ class AppWorkflow( action { when (output) { is HomeOutput.CloseApp -> setOutput(Unit) - is HomeOutput.SignOut -> loginRepository.auth = null + is HomeOutput.SignOut -> apiProvider.signOut() } } } diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/notifications/NotificationsRepository.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/notifications/NotificationsRepository.kt index 52b7dcde..d846c6de 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/notifications/NotificationsRepository.kt +++ b/app/common/src/commonMain/kotlin/sh/christian/ozone/notifications/NotificationsRepository.kt @@ -31,6 +31,7 @@ import sh.christian.ozone.app.Supervisor import sh.christian.ozone.di.SingleInApp import sh.christian.ozone.error.ErrorProps import sh.christian.ozone.error.toErrorProps +import sh.christian.ozone.login.LoginRepository import sh.christian.ozone.model.Notifications import sh.christian.ozone.model.TimelinePost import sh.christian.ozone.model.toNotification @@ -43,6 +44,7 @@ import kotlin.time.Duration.Companion.minutes @SingleInApp class NotificationsRepository( private val apiProvider: ApiProvider, + private val loginRepository: LoginRepository, ) : Supervisor() { private val latest: MutableStateFlow = MutableStateFlow(EMPTY_VALUE) private val loadErrors: MutableSharedFlow = MutableSharedFlow() @@ -59,7 +61,7 @@ class NotificationsRepository( @OptIn(ExperimentalCoroutinesApi::class) override suspend fun CoroutineScope.onStart() { - apiProvider.auth() + loginRepository.authFlow() .flatMapLatest { auth -> if (auth != null) { flow { diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/timeline/TimelineRepository.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/timeline/TimelineRepository.kt index 7248d71e..97bd4486 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/timeline/TimelineRepository.kt +++ b/app/common/src/commonMain/kotlin/sh/christian/ozone/timeline/TimelineRepository.kt @@ -14,6 +14,7 @@ import sh.christian.ozone.app.Supervisor import sh.christian.ozone.di.SingleInApp import sh.christian.ozone.error.ErrorProps import sh.christian.ozone.error.toErrorProps +import sh.christian.ozone.login.LoginRepository import sh.christian.ozone.model.Timeline import sh.christian.ozone.util.toReadOnlyList @@ -21,6 +22,7 @@ import sh.christian.ozone.util.toReadOnlyList @SingleInApp class TimelineRepository( private val apiProvider: ApiProvider, + private val loginRepository: LoginRepository, ): Supervisor() { private val latest: MutableStateFlow = MutableStateFlow(null) private val loadErrors: MutableSharedFlow = MutableSharedFlow() @@ -29,7 +31,7 @@ class TimelineRepository( val errors: Flow = loadErrors override suspend fun CoroutineScope.onStart() { - apiProvider.auth().filter { it == null }.collect { + loginRepository.authFlow().filter { it == null }.collect { latest.value = null } } diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/user/MyProfileRepository.kt b/app/common/src/commonMain/kotlin/sh/christian/ozone/user/MyProfileRepository.kt index d0014e09..35dfbe12 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/user/MyProfileRepository.kt +++ b/app/common/src/commonMain/kotlin/sh/christian/ozone/user/MyProfileRepository.kt @@ -10,6 +10,7 @@ import me.tatarka.inject.annotations.Inject import sh.christian.ozone.api.ApiProvider import sh.christian.ozone.app.Supervisor import sh.christian.ozone.di.SingleInApp +import sh.christian.ozone.login.LoginRepository import sh.christian.ozone.model.FullProfile @Inject @@ -17,12 +18,13 @@ import sh.christian.ozone.model.FullProfile class MyProfileRepository( private val apiProvider: ApiProvider, private val userDatabase: UserDatabase, + private val loginRepository: LoginRepository, ) : Supervisor() { private val profileFlow = MutableStateFlow(null) @OptIn(ExperimentalCoroutinesApi::class) override suspend fun CoroutineScope.onStart() { - apiProvider.auth().flatMapLatest { auth -> + loginRepository.authFlow().flatMapLatest { auth -> auth?.did ?.let { did -> userDatabase.profile(UserDid(did)) } ?: flowOf(null) diff --git a/bluesky/api/bluesky.api b/bluesky/api/bluesky.api index 07e6afaf..84d5b603 100644 --- a/bluesky/api/bluesky.api +++ b/bluesky/api/bluesky.api @@ -14685,6 +14685,224 @@ public final class sh/christian/ozone/XrpcBlueskyApi : sh/christian/ozone/Bluesk public fun upsertSet (Ltools/ozone/set/Set;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi : sh/christian/ozone/BlueskyApi { + public fun (Lio/ktor/client/HttpClient;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;)V + public synthetic fun (Lio/ktor/client/HttpClient;Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lsh/christian/ozone/XrpcBlueskyApi;Lkotlinx/coroutines/flow/MutableStateFlow;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun activateAccount (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addMember (Ltools/ozone/team/AddMemberRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addReservedHandle (Lcom/atproto/temp/AddReservedHandleRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun addValues (Ltools/ozone/set/AddValuesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun applyWrites (Lcom/atproto/repo/ApplyWritesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun checkAccountStatus (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun checkSignupQueue (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clearCredentials ()V + public fun confirmEmail (Lcom/atproto/server/ConfirmEmailRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createAccount (Lcom/atproto/server/CreateAccountRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createAppPassword (Lcom/atproto/server/CreateAppPasswordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createInviteCode (Lcom/atproto/server/CreateInviteCodeRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createInviteCodes (Lcom/atproto/server/CreateInviteCodesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createRecord (Lcom/atproto/repo/CreateRecordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createReport (Lcom/atproto/moderation/CreateReportRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createSession (Lcom/atproto/server/CreateSessionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createTemplate (Ltools/ozone/communication/CreateTemplateRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deactivateAccount (Lcom/atproto/server/DeactivateAccountRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAccount (Lcom/atproto/admin/DeleteAccountRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAccount (Lcom/atproto/server/DeleteAccountRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAccount (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteMember (Ltools/ozone/team/DeleteMemberRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteMessageForSelf (Lchat/bsky/convo/DeleteMessageForSelfRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteRecord (Lcom/atproto/repo/DeleteRecordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteSet (Ltools/ozone/set/DeleteSetRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteTemplate (Ltools/ozone/communication/DeleteTemplateRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteValues (Ltools/ozone/set/DeleteValuesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun describeFeedGenerator (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun describeRepo (Lcom/atproto/repo/DescribeRepoQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun describeServer (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun disableAccountInvites (Lcom/atproto/admin/DisableAccountInvitesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun disableInviteCodes (Lcom/atproto/admin/DisableInviteCodesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun emitEvent (Ltools/ozone/moderation/EmitEventRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun enableAccountInvites (Lcom/atproto/admin/EnableAccountInvitesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun exportAccountData (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun findCorrelation (Ltools/ozone/signature/FindCorrelationQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun findRelatedAccounts (Ltools/ozone/signature/FindRelatedAccountsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAccountInfo (Lcom/atproto/admin/GetAccountInfoQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAccountInfos (Lcom/atproto/admin/GetAccountInfosQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAccountInviteCodes (Lcom/atproto/server/GetAccountInviteCodesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActorFeeds (Lapp/bsky/feed/GetActorFeedsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActorLikes (Lapp/bsky/feed/GetActorLikesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActorMetadata (Lchat/bsky/moderation/GetActorMetadataQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getActorStarterPacks (Lapp/bsky/graph/GetActorStarterPacksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getAuthTokens ()Lkotlinx/coroutines/flow/StateFlow; + public fun getAuthorFeed (Lapp/bsky/feed/GetAuthorFeedQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getBlob (Lcom/atproto/sync/GetBlobQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getBlocks (Lapp/bsky/graph/GetBlocksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getBlocks (Lcom/atproto/sync/GetBlocksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getConfig (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getConvo (Lchat/bsky/convo/GetConvoQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getConvoForMembers (Lchat/bsky/convo/GetConvoForMembersQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getEvent (Ltools/ozone/moderation/GetEventQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFeed (Lapp/bsky/feed/GetFeedQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFeedGenerator (Lapp/bsky/feed/GetFeedGeneratorQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFeedGenerators (Lapp/bsky/feed/GetFeedGeneratorsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFeedSkeleton (Lapp/bsky/feed/GetFeedSkeletonQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFollowers (Lapp/bsky/graph/GetFollowersQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFollows (Lapp/bsky/graph/GetFollowsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getInviteCodes (Lcom/atproto/admin/GetInviteCodesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getJobStatus (Lapp/bsky/video/GetJobStatusQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getKnownFollowers (Lapp/bsky/graph/GetKnownFollowersQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getLatestCommit (Lcom/atproto/sync/GetLatestCommitQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getLikes (Lapp/bsky/feed/GetLikesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getList (Lapp/bsky/graph/GetListQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getListBlocks (Lapp/bsky/graph/GetListBlocksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getListFeed (Lapp/bsky/feed/GetListFeedQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getListMutes (Lapp/bsky/graph/GetListMutesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getLists (Lapp/bsky/graph/GetListsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getLog (Lchat/bsky/convo/GetLogQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getMessageContext (Lchat/bsky/moderation/GetMessageContextQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getMessages (Lchat/bsky/convo/GetMessagesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getMutes (Lapp/bsky/graph/GetMutesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getPostThread (Lapp/bsky/feed/GetPostThreadQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getPosts (Lapp/bsky/feed/GetPostsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getPreferences (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getProfile (Lapp/bsky/actor/GetProfileQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getProfiles (Lapp/bsky/actor/GetProfilesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getQuotes (Lapp/bsky/feed/GetQuotesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRecommendedDidCredentials (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRecord (Lcom/atproto/repo/GetRecordQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRecord (Lcom/atproto/sync/GetRecordQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRecord (Ltools/ozone/moderation/GetRecordQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRecords (Ltools/ozone/moderation/GetRecordsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRelationships (Lapp/bsky/graph/GetRelationshipsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRepo (Lcom/atproto/sync/GetRepoQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRepo (Ltools/ozone/moderation/GetRepoQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRepoStatus (Lcom/atproto/sync/GetRepoStatusQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRepos (Ltools/ozone/moderation/GetReposQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getRepostedBy (Lapp/bsky/feed/GetRepostedByQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getServiceAuth (Lcom/atproto/server/GetServiceAuthQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getServices (Lapp/bsky/labeler/GetServicesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getStarterPack (Lapp/bsky/graph/GetStarterPackQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getStarterPacks (Lapp/bsky/graph/GetStarterPacksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSubjectStatus (Lcom/atproto/admin/GetSubjectStatusQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSuggestedFeeds (Lapp/bsky/feed/GetSuggestedFeedsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSuggestedFollowsByActor (Lapp/bsky/graph/GetSuggestedFollowsByActorQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSuggestions (Lapp/bsky/actor/GetSuggestionsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTimeline (Lapp/bsky/feed/GetTimelineQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getUnreadCount (Lapp/bsky/notification/GetUnreadCountQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getUploadLimits (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getValues (Ltools/ozone/set/GetValuesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun importRepo ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun leaveConvo (Lchat/bsky/convo/LeaveConvoRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listAppPasswords (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listBlobs (Lcom/atproto/sync/ListBlobsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listConvos (Lchat/bsky/convo/ListConvosQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listMembers (Ltools/ozone/team/ListMembersQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listMissingBlobs (Lcom/atproto/repo/ListMissingBlobsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listNotifications (Lapp/bsky/notification/ListNotificationsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listOptions (Ltools/ozone/setting/ListOptionsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listRecords (Lcom/atproto/repo/ListRecordsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listRepos (Lcom/atproto/sync/ListReposQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listTemplates (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun muteActor (Lapp/bsky/graph/MuteActorRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun muteActorList (Lapp/bsky/graph/MuteActorListRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun muteConvo (Lchat/bsky/convo/MuteConvoRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun muteThread (Lapp/bsky/graph/MuteThreadRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun notifyOfUpdate (Lcom/atproto/sync/NotifyOfUpdateRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putPreferences (Lapp/bsky/actor/PutPreferencesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putPreferences (Lapp/bsky/notification/PutPreferencesRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putRecord (Lcom/atproto/repo/PutRecordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun queryEvents (Ltools/ozone/moderation/QueryEventsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun queryLabels (Lcom/atproto/label/QueryLabelsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun querySets (Ltools/ozone/set/QuerySetsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun queryStatuses (Ltools/ozone/moderation/QueryStatusesQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun refreshSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun registerPush (Lapp/bsky/notification/RegisterPushRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun removeOptions (Ltools/ozone/setting/RemoveOptionsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestAccountDelete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestCrawl (Lcom/atproto/sync/RequestCrawlRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestEmailConfirmation (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestEmailUpdate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestPasswordReset (Lcom/atproto/server/RequestPasswordResetRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestPhoneVerification (Lcom/atproto/temp/RequestPhoneVerificationRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun requestPlcOperationSignature (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun reserveSigningKey (Lcom/atproto/server/ReserveSigningKeyRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun resetPassword (Lcom/atproto/server/ResetPasswordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun resolveHandle (Lcom/atproto/identity/ResolveHandleQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun revokeAppPassword (Lcom/atproto/server/RevokeAppPasswordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchAccounts (Lcom/atproto/admin/SearchAccountsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchAccounts (Ltools/ozone/signature/SearchAccountsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchActors (Lapp/bsky/actor/SearchActorsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchActorsTypeahead (Lapp/bsky/actor/SearchActorsTypeaheadQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchPosts (Lapp/bsky/feed/SearchPostsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchRepos (Ltools/ozone/moderation/SearchReposQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun searchStarterPacks (Lapp/bsky/graph/SearchStarterPacksQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendEmail (Lcom/atproto/admin/SendEmailRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendInteractions (Lapp/bsky/feed/SendInteractionsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendMessage (Lchat/bsky/convo/SendMessageRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun sendMessageBatch (Lchat/bsky/convo/SendMessageBatchRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun signPlcOperation (Lcom/atproto/identity/SignPlcOperationRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun submitPlcOperation (Lcom/atproto/identity/SubmitPlcOperationRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeLabels (Lcom/atproto/label/SubscribeLabelsQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun subscribeRepos (Lcom/atproto/sync/SubscribeReposQueryParams;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun unmuteActor (Lapp/bsky/graph/UnmuteActorRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun unmuteActorList (Lapp/bsky/graph/UnmuteActorListRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun unmuteConvo (Lchat/bsky/convo/UnmuteConvoRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun unmuteThread (Lapp/bsky/graph/UnmuteThreadRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateAccountEmail (Lcom/atproto/admin/UpdateAccountEmailRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateAccountHandle (Lcom/atproto/admin/UpdateAccountHandleRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateAccountPassword (Lcom/atproto/admin/UpdateAccountPasswordRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateActorAccess (Lchat/bsky/moderation/UpdateActorAccessRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateEmail (Lcom/atproto/server/UpdateEmailRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateHandle (Lcom/atproto/identity/UpdateHandleRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateMember (Ltools/ozone/team/UpdateMemberRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateRead (Lchat/bsky/convo/UpdateReadRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateSeen (Lapp/bsky/notification/UpdateSeenRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateSubjectStatus (Lcom/atproto/admin/UpdateSubjectStatusRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateTemplate (Ltools/ozone/communication/UpdateTemplateRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun uploadBlob ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun uploadVideo ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun upsertOption (Ltools/ozone/setting/UpsertOptionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun upsertSet (Ltools/ozone/set/Set;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class sh/christian/ozone/api/BlueskyAuthPlugin { + public static final field Companion Lsh/christian/ozone/api/BlueskyAuthPlugin$Companion; + public fun (Lkotlinx/serialization/json/Json;Lkotlinx/coroutines/flow/MutableStateFlow;)V +} + +public final class sh/christian/ozone/api/BlueskyAuthPlugin$Companion : io/ktor/client/plugins/HttpClientPlugin { + public fun getKey ()Lio/ktor/util/AttributeKey; + public synthetic fun install (Ljava/lang/Object;Lio/ktor/client/HttpClient;)V + public fun install (Lsh/christian/ozone/api/BlueskyAuthPlugin;Lio/ktor/client/HttpClient;)V + public synthetic fun prepare (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public fun prepare (Lkotlin/jvm/functions/Function1;)Lsh/christian/ozone/api/BlueskyAuthPlugin; +} + +public final class sh/christian/ozone/api/BlueskyAuthPlugin$Config { + public fun ()V + public fun (Lkotlinx/serialization/json/Json;Lkotlinx/coroutines/flow/MutableStateFlow;)V + public synthetic fun (Lkotlinx/serialization/json/Json;Lkotlinx/coroutines/flow/MutableStateFlow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAuthTokens ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun getJson ()Lkotlinx/serialization/json/Json; + public final fun setAuthTokens (Lkotlinx/coroutines/flow/MutableStateFlow;)V + public final fun setJson (Lkotlinx/serialization/json/Json;)V +} + +public final class sh/christian/ozone/api/BlueskyAuthPlugin$Tokens { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens; + public static synthetic fun copy$default (Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lsh/christian/ozone/api/BlueskyAuthPlugin$Tokens; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuth ()Ljava/lang/String; + public final fun getRefresh ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class sh/christian/ozone/api/xrpc/FindSubscriptionSerializerKt { public static final fun findSubscriptionSerializer (Lkotlin/reflect/KClass;Ljava/lang/String;)Lkotlinx/serialization/KSerializer; } diff --git a/bluesky/src/commonMain/kotlin/sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi.kt b/bluesky/src/commonMain/kotlin/sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi.kt new file mode 100644 index 00000000..af5f62f6 --- /dev/null +++ b/bluesky/src/commonMain/kotlin/sh/christian/ozone/api/AuthenticatedXrpcBlueskyApi.kt @@ -0,0 +1,124 @@ +package sh.christian.ozone.api + +import com.atproto.server.CreateAccountRequest +import com.atproto.server.CreateAccountResponse +import com.atproto.server.CreateSessionRequest +import com.atproto.server.CreateSessionResponse +import com.atproto.server.RefreshSessionResponse +import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import sh.christian.ozone.BlueskyApi +import sh.christian.ozone.XrpcBlueskyApi +import sh.christian.ozone.api.response.AtpResponse +import sh.christian.ozone.api.xrpc.defaultHttpClient + +/** + * Wrapper around [XrpcBlueskyApi] to transparently manage session tokens on the user's behalf. + * + * This class is responsible for saving and restoring session tokens from the server's responses. The session will also + * automatically be refreshed and retried using the refresh token if any method call results in an `ExpiredToken` error. + * Use of one of the following methods will automatically start a session and save the tokens: + * - [createAccount] + * - [createSession] + * - [refreshSession] + * + * Use of [deleteSession] will clear the session. The session can also be manually cleared by calling [clearCredentials] + * which will forget, but not invalidate, the current session tokens. + */ +class AuthenticatedXrpcBlueskyApi +private constructor( + private val delegate: XrpcBlueskyApi, + private val _authTokens: MutableStateFlow, +) : BlueskyApi by delegate { + /** + * The current session tokens. This will be `null` if the user is not authenticated. + */ + val authTokens: StateFlow get() = _authTokens.asStateFlow() + + private constructor( + httpClient: HttpClient, + authTokens: MutableStateFlow, + ) : this( + delegate = XrpcBlueskyApi(httpClient.withBlueskyAuth(authTokens)), + _authTokens = authTokens, + ) + + /** + * Creates a new instance of [AuthenticatedXrpcBlueskyApi] + * + * @param httpClient Optional [HttpClient] to use for network requests. This instance will be configured to work with + * basic XRPC API instances. + * @param initialTokens Optional initial session tokens to use. If not provided, the API will start unauthenticated. + */ + constructor( + httpClient: HttpClient = defaultHttpClient, + initialTokens: BlueskyAuthPlugin.Tokens? = null, + ) : this(httpClient, MutableStateFlow(initialTokens)) + + override suspend fun createAccount(request: CreateAccountRequest): AtpResponse { + return delegate.createAccount(request).also { + it.saveTokens({ accessJwt }, { refreshJwt }) + } + } + + override suspend fun createSession(request: CreateSessionRequest): AtpResponse { + return delegate.createSession(request).also { + it.saveTokens({ accessJwt }, { refreshJwt }) + } + } + + override suspend fun refreshSession(): AtpResponse { + return delegate.refreshSession().also { + it.saveTokens({ accessJwt }, { refreshJwt }) + } + } + + override suspend fun deleteSession(): AtpResponse { + return delegate.deleteSession().also { + if (it is AtpResponse.Success) { + clearCredentials() + } + } + } + + /** + * Clears the current session tokens, effectively logging the user out. Note that the session and refresh token are + * **not invalidated** on the server side, unlike calling [deleteSession]. + */ + fun clearCredentials() { + _authTokens.value = null + } + + private inline fun AtpResponse.saveTokens( + accessTokenProvider: T.() -> String, + refreshTokenProvider: T.() -> String, + ) { + if (this is AtpResponse.Success) { + _authTokens.value = BlueskyAuthPlugin.Tokens( + auth = accessTokenProvider(response), + refresh = refreshTokenProvider(response), + ) + } + } + + private companion object { + /** + * Wraps an [XrpcBlueskyApi] instance as an [AuthenticatedXrpcBlueskyApi] with the optional initial tokens. + */ + fun XrpcBlueskyApi.authenticated(initialTokens: BlueskyAuthPlugin.Tokens? = null): AuthenticatedXrpcBlueskyApi { + return AuthenticatedXrpcBlueskyApi(this, MutableStateFlow(initialTokens)) + } + + private fun HttpClient.withBlueskyAuth( + authTokens: MutableStateFlow, + ): HttpClient { + return config { + install(BlueskyAuthPlugin) { + this.authTokens = authTokens + } + } + } + } +} diff --git a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/XrpcAuthPlugin.kt b/bluesky/src/commonMain/kotlin/sh/christian/ozone/api/BlueskyAuthPlugin.kt similarity index 69% rename from app/common/src/commonMain/kotlin/sh/christian/ozone/api/XrpcAuthPlugin.kt rename to bluesky/src/commonMain/kotlin/sh/christian/ozone/api/BlueskyAuthPlugin.kt index 52a853bc..1aa789ab 100644 --- a/app/common/src/commonMain/kotlin/sh/christian/ozone/api/XrpcAuthPlugin.kt +++ b/bluesky/src/commonMain/kotlin/sh/christian/ozone/api/BlueskyAuthPlugin.kt @@ -3,7 +3,6 @@ package sh.christian.ozone.api import com.atproto.server.RefreshSessionResponse import io.ktor.client.HttpClient import io.ktor.client.call.HttpClientCall -import io.ktor.client.call.body import io.ktor.client.call.save import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.plugins.HttpSend @@ -14,34 +13,42 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpHeaders.Authorization import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.util.AttributeKey +import io.ktor.utils.io.KtorDsl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.json.Json import sh.christian.ozone.BlueskyJson import sh.christian.ozone.api.response.AtpErrorDescription +import sh.christian.ozone.api.xrpc.toAtpResponse /** * Appends the `Authorization` header to XRPC requests, as well as automatically refreshing and * replaying a network request if it fails due to an expired access token. */ -internal class XrpcAuthPlugin( +class BlueskyAuthPlugin( private val json: Json, private val authTokens: MutableStateFlow, ) { + @KtorDsl class Config( var json: Json = BlueskyJson, var authTokens: MutableStateFlow = MutableStateFlow(null), ) - companion object : HttpClientPlugin { - override val key = AttributeKey("XrpcAuthPlugin") + data class Tokens( + val auth: String, + val refresh: String, + ) + + companion object : HttpClientPlugin { + override val key = AttributeKey("BlueskyAuthPlugin") - override fun prepare(block: Config.() -> Unit): XrpcAuthPlugin { + override fun prepare(block: Config.() -> Unit): BlueskyAuthPlugin { val config = Config().apply(block) - return XrpcAuthPlugin(config.json, config.authTokens) + return BlueskyAuthPlugin(config.json, config.authTokens) } override fun install( - plugin: XrpcAuthPlugin, + plugin: BlueskyAuthPlugin, scope: HttpClient, ) { scope.plugin(HttpSend).intercept { context -> @@ -50,6 +57,7 @@ internal class XrpcAuthPlugin( } var result: HttpClientCall = execute(context) + if (result.response.status != BadRequest) { return@intercept result } @@ -65,16 +73,17 @@ internal class XrpcAuthPlugin( val refreshResponse = scope.post("/xrpc/com.atproto.server.refreshSession") { plugin.authTokens.value?.refresh?.let { bearerAuth(it) } } - runCatching { refreshResponse.body() }.getOrNull()?.let { refreshed -> - val newAccessToken = refreshed.accessJwt - val newRefreshToken = refreshed.refreshJwt - plugin.authTokens.value = Tokens(newAccessToken, newRefreshToken) + refreshResponse.toAtpResponse().maybeResponse()?.let { refreshed -> + val newAccessToken = refreshed.accessJwt + val newRefreshToken = refreshed.refreshJwt - context.headers.remove(Authorization) - context.bearerAuth(newAccessToken) - result = execute(context) - } + plugin.authTokens.value = Tokens(newAccessToken, newRefreshToken) + + context.headers.remove(Authorization) + context.bearerAuth(newAccessToken) + result = execute(context) + } } result