Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d965245
Add themes inspired by a Heron's plumage
tunjid Jan 29, 2026
ba7ef99
Refactor Theme class declaration
tunjid Jan 29, 2026
cc9fd9d
Allow app accept different themes
tunjid Jan 29, 2026
f23f26f
Update them colors
tunjid Jan 29, 2026
01e60e7
Bind unused theme declaration
tunjid Jan 30, 2026
9f5f791
Fix typo
tunjid Jan 30, 2026
3e13185
Merge pull request #910 from tunjid/tj/heron-themes
tunjid Jan 30, 2026
f337645
Implement account switching business logic
joelmuraguri Jan 30, 2026
a00e9da
Set Profile AuthToken
joelmuraguri Jan 30, 2026
b97fd22
Refactor session switching to use atomic SavedState updates
joelmuraguri Jan 30, 2026
ca0513c
Remove unused helper method
joelmuraguri Jan 30, 2026
660104f
Fix account switching to properly expire sessions
joelmuraguri Jan 30, 2026
f0c2ce3
Adjust write timeouts depending on what's written
tunjid Jan 30, 2026
1df654e
Remove redundant return expiredSessionOutCome
joelmuraguri Jan 30, 2026
af4aecd
Merge pull request #911 from tunjid/joel/account-switching-data-layer
tunjid Jan 30, 2026
d9e8097
Merge pull request #912 from tunjid/tj/write-queue-timeouts
tunjid Jan 30, 2026
99ba4ea
Animated logo WIP
tunjid Jan 31, 2026
38bfc6d
Animate logo progress
tunjid Jan 31, 2026
a346120
Update method property names
tunjid Jan 31, 2026
9ac70cc
Update colors
tunjid Jan 31, 2026
15804d1
Clip circle
tunjid Jan 31, 2026
9f3bb3e
lint
tunjid Jan 31, 2026
6928cd0
Add constants
tunjid Jan 31, 2026
f712a84
Fix typo
tunjid Jan 31, 2026
a7d86c6
Merge pull request #913 from tunjid/tj/animated-logo
tunjid Jan 31, 2026
267a635
Remove deprecated fields
tunjid Jan 31, 2026
0ba2f90
Merge pull request #914 from tunjid/tj/deprecation-cleanup
tunjid Jan 31, 2026
cc10f9c
Check if a session switch was actually completed
tunjid Jan 31, 2026
b518340
PR feedback
tunjid Jan 31, 2026
29eded9
PR suggestions
tunjid Jan 31, 2026
9ddbea0
Merge pull request #915 from tunjid/tj/session-switch-check
tunjid Jan 31, 2026
478af80
Prevent users from getting stuck on the sign in screen
tunjid Jan 31, 2026
186b940
Merge pull request #916 from tunjid/tj/stuck-sign-in
tunjid Jan 31, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ private fun post(
is VideoEntity -> embedEntity.asExternalModel()
null -> null
},
quote = quote,
viewerStats = viewerStatisticsEntity?.asExternalModel(),
viewerState = viewerStateEntity?.asExternalModel(),
labels = labels,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.tunjid.heron.data.core.models.SessionSummary
import com.tunjid.heron.data.core.models.TimelinePreference
import com.tunjid.heron.data.core.types.GenericUri
import com.tunjid.heron.data.core.types.ProfileId
import com.tunjid.heron.data.core.types.SessionSwitchException
import com.tunjid.heron.data.core.utilities.Outcome
import com.tunjid.heron.data.database.daos.ProfileDao
import com.tunjid.heron.data.database.entities.PopulatedProfileEntity
Expand All @@ -40,6 +41,7 @@ import com.tunjid.heron.data.utilities.multipleEntitysaver.add
import com.tunjid.heron.data.utilities.preferenceupdater.NotificationPreferenceUpdater
import com.tunjid.heron.data.utilities.preferenceupdater.PreferenceUpdater
import com.tunjid.heron.data.utilities.runCatchingUnlessCancelled
import com.tunjid.heron.data.utilities.toOutcome
import com.tunjid.heron.data.utilities.withRefresh
import dev.zacsweers.metro.Inject
import kotlin.time.Clock
Expand Down Expand Up @@ -74,6 +76,10 @@ interface AuthRepository {
request: SessionRequest,
): Outcome

suspend fun switchSession(
sessionSummary: SessionSummary,
): Outcome

suspend fun signOut()

suspend fun updateSignedInUser(): Outcome
Expand Down Expand Up @@ -166,6 +172,33 @@ internal class AuthTokenRepository(
onFailure = Outcome::Failure,
)

override suspend fun switchSession(
sessionSummary: SessionSummary,
): Outcome = runCatchingUnlessCancelled {
// Switching should cause the current session to expire
val switched = savedStateDataSource.inCurrentProfileSession { signedInProfileId ->
if (signedInProfileId == sessionSummary.profileId) return@inCurrentProfileSession true

savedStateDataSource.switchSession(
profileId = sessionSummary.profileId,
)

false
} ?: true

if (!switched) return@runCatchingUnlessCancelled Outcome.Failure(
SessionSwitchException(sessionSummary.profileId),
)

savedStateDataSource.inCurrentProfileSession { signedInProfileId ->
if (signedInProfileId == sessionSummary.profileId) {
updateSignedInUser()
} else {
expiredSessionOutcome()
}
} ?: expiredSessionOutcome()
}.toOutcome()

override suspend fun signOut() {
runCatchingUnlessCancelled {
sessionManager.endSession()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ import com.tunjid.heron.data.core.models.Link
import com.tunjid.heron.data.core.models.Post
import com.tunjid.heron.data.core.models.PostUri
import com.tunjid.heron.data.core.models.ProfileWithViewerState
import com.tunjid.heron.data.core.models.Record
import com.tunjid.heron.data.core.models.TimelineItem
import com.tunjid.heron.data.core.models.offset
import com.tunjid.heron.data.core.models.value
Expand Down Expand Up @@ -750,11 +749,7 @@ internal class OfflinePostRepository @Inject constructor(
text = text,
reply = reply,
embed = postEmbedUnion(
embeddedRecordReference = metadata.embeddedRecordReference
?: metadata
.quote
?.interaction
?.let { Record.Reference(it.postId, it.postUri) },
embeddedRecordReference = metadata.embeddedRecordReference,
mediaBlobs = blobs,
),
facets = resolvedLinks.facet(),
Expand All @@ -765,33 +760,26 @@ internal class OfflinePostRepository @Inject constructor(

private suspend fun Post.Create.Request.mediaBlobs(): Result<List<MediaBlob>> =
runCatchingUnlessCancelled {
val blobs = when {
metadata.embeddedMedia.isNotEmpty() -> coroutineScope {
metadata.embeddedMedia.map { file ->
async {
when (file) {
is File.Media.Photo -> fileManager.source(file).use {
networkService.uploadImageBlob(data = it)
}
is File.Media.Video -> videoUploadService.uploadVideo(
file = file,
)
val blobs = coroutineScope {
metadata.embeddedMedia.map { file ->
async {
when (file) {
is File.Media.Photo -> fileManager.source(file).use {
networkService.uploadImageBlob(data = it)
}
.map(file::with)
.onSuccess { fileManager.delete(file) }
is File.Media.Video -> videoUploadService.uploadVideo(
file = file,
)
}
.map(file::with)
.onSuccess { fileManager.delete(file) }
}
.awaitAll()
.mapNotNull(Result<MediaBlob?>::getOrNull)
}
@Suppress("DEPRECATION")
// Deprecated media upload path is no longer supported
metadata.mediaFiles.isNotEmpty() -> emptyList()
else -> emptyList()
.awaitAll()
.mapNotNull(Result<MediaBlob?>::getOrNull)
}

val mediaToUploadCount = metadata.embeddedMedia.size.takeIf { it > 0 }
?: @Suppress("DEPRECATION") metadata.mediaFiles.size
val mediaToUploadCount = metadata.embeddedMedia.size

if (mediaToUploadCount > 0 && blobs.size != mediaToUploadCount) {
throw Exception("Media upload failed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.serialization.Serializable
import sh.christian.ozone.api.AtUri
import sh.christian.ozone.api.Did
Expand Down Expand Up @@ -647,38 +646,22 @@ internal class OfflineProfileRepository @Inject constructor(
if (signedInProfileId == null) return@inCurrentProfileSession expiredSessionOutcome()

coroutineScope {
val (avatarBlob, bannerBlob) = @Suppress("DEPRECATION")
when {
update.avatarFile != null || update.bannerFile != null -> listOf(
update.avatarFile,
update.bannerFile,
).map { file ->
async {
if (file == null) return@async null
networkService.runCatchingWithMonitoredNetworkRetry {
fileManager.source(file).use { source ->
uploadBlob(ByteReadChannel(source))
}
}
.onSuccess { fileManager.delete(file) }
.getOrNull()
?.blob
}
}.awaitAll()
else -> listOf(
update.avatar,
update.banner,
).map { file ->
async {
if (file == null) null
else networkService.runCatchingWithMonitoredNetworkRetry {
uploadBlob(ByteReadChannel(file.data))
val (avatarBlob, bannerBlob) = listOf(
update.avatarFile,
update.bannerFile,
).map { file ->
async {
if (file == null) return@async null
networkService.runCatchingWithMonitoredNetworkRetry {
fileManager.source(file).use { source ->
uploadBlob(ByteReadChannel(source))
}
.getOrNull()
?.blob
}
}.awaitAll()
}
.onSuccess { fileManager.delete(file) }
.getOrNull()
?.blob
}
}.awaitAll()

networkService.runCatchingWithMonitoredNetworkRetry {
getRecord(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ internal sealed class SavedStateDataSource {
auth: SavedState.AuthTokens?,
)

internal abstract suspend fun switchSession(
profileId: ProfileId,
)

internal abstract suspend fun updateSignedInProfileData(
block: suspend SavedState.ProfileData.(signedInProfileId: ProfileId?) -> SavedState.ProfileData,
)
Expand Down Expand Up @@ -363,6 +367,16 @@ internal class DataStoreSavedStateDataSource(
)
}

override suspend fun switchSession(
profileId: ProfileId,
) = updateState {
val profileData = profileData[profileId]
val authenticated = profileData?.auth as? SavedState.AuthTokens.Authenticated

if (profileData == null || authenticated == null) this
else copy(activeProfileId = profileId)
}

override suspend fun updateSignedInProfileData(
block: suspend SavedState.ProfileData.(signedInProfileId: ProfileId?) -> SavedState.ProfileData,
) = updateState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import app.bsky.richtext.FacetTag
import com.atproto.repo.StrongRef
import com.tunjid.heron.data.core.models.Link
import com.tunjid.heron.data.core.models.LinkTarget
import com.tunjid.heron.data.core.models.MediaFile
import com.tunjid.heron.data.core.models.Record
import com.tunjid.heron.data.core.utilities.File
import sh.christian.ozone.api.AtUri
Expand All @@ -46,45 +45,20 @@ import sh.christian.ozone.api.model.Blob

internal sealed class MediaBlob {
sealed class Image : MediaBlob() {
data class Deprecated(
@Suppress("DEPRECATION")
val file: MediaFile.Photo,
val blob: Blob,
) : Image()

data class Local(
val file: File.Media.Photo,
val blob: Blob,
) : Image()
}

sealed class Video : MediaBlob() {
data class Deprecated(
@Suppress("DEPRECATION")
val file: MediaFile.Video,
val blob: Blob,
) : Video()

data class Local(
val file: File.Media.Video,
val blob: Blob,
) : Video()
}
}

@Suppress("DEPRECATION")
internal fun MediaFile.with(blob: Blob) = when (this) {
is MediaFile.Photo -> MediaBlob.Image.Deprecated(
file = this,
blob = blob,
)

is MediaFile.Video -> MediaBlob.Video.Deprecated(
file = this,
blob = blob,
)
}

internal fun File.Media.with(blob: Blob) = when (this) {
is File.Media.Photo -> MediaBlob.Image.Local(
file = this,
Expand Down Expand Up @@ -163,14 +137,6 @@ private fun List<MediaBlob>.video(): BskyVideo? =
.firstOrNull()
?.let { videoFile ->
when (videoFile) {
is MediaBlob.Video.Deprecated -> BskyVideo(
video = videoFile.blob,
alt = videoFile.file.altText,
aspectRatio = AspectRatio(
videoFile.file.width,
videoFile.file.height,
),
)
is MediaBlob.Video.Local -> BskyVideo(
video = videoFile.blob,
alt = videoFile.file.altText,
Expand All @@ -186,14 +152,6 @@ private fun List<MediaBlob>.images(): BskyImages? =
filterIsInstance<MediaBlob.Image>()
.map { photoFile ->
when (photoFile) {
is MediaBlob.Image.Deprecated -> ImagesImage(
image = photoFile.blob,
alt = photoFile.file.altText,
aspectRatio = AspectRatio(
photoFile.file.width,
photoFile.file.height,
),
)
is MediaBlob.Image.Local -> ImagesImage(
image = photoFile.blob,
alt = photoFile.file.altText ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.tunjid.heron.data.utilities.writequeue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshotFlow
import com.tunjid.heron.data.core.types.ProfileId
import com.tunjid.heron.data.core.utilities.File
import com.tunjid.heron.data.core.utilities.Outcome
import com.tunjid.heron.data.network.NetworkConnectionException
import com.tunjid.heron.data.repository.MessageRepository
Expand Down Expand Up @@ -220,8 +221,10 @@ internal class PersistedWriteQueue @Inject constructor(
) = flow {
emit(
Pair(
writable,
with(writable) { withTimeout(WriteTimeout) { write() } },
first = writable,
second = with(writable) {
withTimeout(writable.writeTimeout()) { write() }
},
),
)
}
Expand Down Expand Up @@ -319,7 +322,35 @@ private suspend inline fun SavedStateDataSource.updateWrites(
}
}

private val WriteTimeout = 10.seconds
private const val MaxConcurrentWrites = 6
private fun Writable.writeTimeout() =
when (this) {
is Writable.Create ->
request.metadata
.embeddedMedia
.fold(BasicWriteTimeout) { timeout, media ->
timeout + when (media) {
is File.Media.Photo -> ImageWriteTimeout
is File.Media.Video -> VideoWriteTimeout
}
}
is Writable.ProfileUpdate ->
BasicWriteTimeout
.plus(if (update.avatarFile != null) ImageWriteTimeout else 0.seconds)
.plus(if (update.bannerFile != null) ImageWriteTimeout else 0.seconds)
is Writable.Connection,
is Writable.Interaction,
is Writable.NotificationUpdate,
is Writable.Reaction,
is Writable.Restriction,
is Writable.Send,
is Writable.TimelineUpdate,
-> BasicWriteTimeout
}

private val VideoWriteTimeout = 60.seconds

private val ImageWriteTimeout = 20.seconds
private val BasicWriteTimeout = 10.seconds
private const val MaxConcurrentWrites = 9
private const val MaximumPendingWrites = 15
private const val MaximumFailedWrites = 10
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@ fun PopulatedPostEntity.asExternalModel(

else -> null
},
quote = null,
record = entity.record?.asExternalModel(),
viewerStats = postStatisticsEntity?.asExternalModel(),
viewerState = viewerStateEntity?.asExternalModel(),
Expand Down
Loading
Loading