Skip to content

Commit 950853f

Browse files
committed
feat(blob): blob storage coordinator + upload-policy cache
Adds the app-layer BlobStorageCoordinator (new :apps:flipcash:shared:blob module) wrapping BlobStorageController. It owns a DataStore-backed upload-policy cache, exposes policy as a self-refreshing Flow (TTL + version invalidation), and offers preloadPolicy()/upload(). The session controller preloads the policy once the user is registered so photo selection can filter/validate locally without a round-trip. Covered by BlobStorageCoordinatorTest. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent cb15428 commit 950853f

6 files changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
plugins {
2+
alias(libs.plugins.flipcash.android.library)
3+
alias(libs.plugins.kotlin.serialization)
4+
}
5+
6+
android {
7+
namespace = "${Gradle.flipcashNamespace}.shared.blob"
8+
}
9+
10+
dependencies {
11+
implementation(libs.bundles.hilt)
12+
implementation(libs.androidx.datastore)
13+
implementation(libs.bundles.kotlinx.serialization)
14+
15+
implementation(project(":libs:coroutines"))
16+
implementation(project(":services:flipcash"))
17+
18+
testImplementation(kotlin("test"))
19+
testImplementation(libs.bundles.unit.testing)
20+
testImplementation(libs.robolectric)
21+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.flipcash.app.blob
2+
3+
import android.content.Context
4+
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
5+
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
6+
import androidx.datastore.preferences.core.Preferences
7+
import androidx.datastore.preferences.core.edit
8+
import androidx.datastore.preferences.core.emptyPreferences
9+
import androidx.datastore.preferences.core.stringPreferencesKey
10+
import androidx.datastore.preferences.preferencesDataStoreFile
11+
import com.flipcash.libs.coroutines.DispatcherProvider
12+
import com.flipcash.services.controllers.BlobStorageController
13+
import com.flipcash.services.models.InitiateExternalUploadError
14+
import com.flipcash.services.models.blob.MimeTypeConstraints
15+
import com.flipcash.services.models.blob.UploadPolicy
16+
import com.flipcash.services.models.chat.BlobId
17+
import dagger.hilt.android.qualifiers.ApplicationContext
18+
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.SupervisorJob
20+
import kotlinx.coroutines.flow.Flow
21+
import kotlinx.coroutines.flow.first
22+
import kotlinx.coroutines.flow.map
23+
import kotlinx.coroutines.flow.onEach
24+
import kotlinx.coroutines.launch
25+
import kotlinx.serialization.json.Json
26+
import java.util.concurrent.atomic.AtomicBoolean
27+
import javax.inject.Inject
28+
import javax.inject.Singleton
29+
import kotlin.time.Duration.Companion.milliseconds
30+
31+
/**
32+
* App-layer facade over blob storage ([BlobStorageController]). ViewModels use this coordinator
33+
* rather than the service-layer controller, mirroring the app's Coordinator → Controller pattern
34+
* (e.g. [ChatCoordinator][com.flipcash.shared.chat.ChatCoordinator]).
35+
*
36+
* It also owns the persisted [UploadPolicy] cache, which honours the policy's own `ttl` and
37+
* `version`: [preloadPolicy] is called on launch by the session controller, [policy] re-fetches
38+
* when the cached copy has aged past its ttl, and [upload] re-fetches when the server rejects an
39+
* upload on policy grounds (a version-mismatch signal).
40+
*/
41+
@Singleton
42+
class BlobStorageCoordinator @Inject constructor(
43+
@param:ApplicationContext private val context: Context,
44+
private val blobStorageController: BlobStorageController,
45+
dispatchers: DispatcherProvider,
46+
) {
47+
private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO)
48+
49+
private val dataStore = PreferenceDataStoreFactory.create(
50+
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
51+
scope = scope,
52+
produceFile = { context.preferencesDataStoreFile("upload-policy") }
53+
)
54+
55+
private val json = Json { ignoreUnknownKeys = true }
56+
57+
private val refreshing = AtomicBoolean(false)
58+
59+
/**
60+
* The upload policy, kept fresh. Emits the cached value and, whenever that value is stale (past
61+
* its `ttl`) or absent, triggers a background refresh whose result re-emits here. Observe it
62+
* directly from a ViewModel; a stale cache resolves to a fresh value without a manual fetch.
63+
*/
64+
val policy: Flow<UploadPolicy?> = dataStore.data
65+
.map { it.decode() }
66+
.onEach { cached -> if (cached == null || !cached.isFresh()) triggerRefresh() }
67+
.map { it?.toDomain() }
68+
69+
/** Fetches the latest upload policy and caches it (stamped with the fetch time). */
70+
suspend fun preloadPolicy(): Result<UploadPolicy> =
71+
blobStorageController.getUploadPolicy().onSuccess { persist(it) }
72+
73+
/**
74+
* Uploads [bytes] to storage and returns the READY [BlobId] — reserve, PUT/POST, complete, and
75+
* poll are all handled inside the controller. A policy-driven rejection invalidates the cached
76+
* policy (the server echoes a newer policy version on such denials).
77+
*/
78+
suspend fun upload(bytes: ByteArray, mimeType: String): Result<BlobId> {
79+
val result = blobStorageController.upload(bytes, mimeType)
80+
result.exceptionOrNull()?.let { refreshIfPolicyChanged(it) }
81+
return result
82+
}
83+
84+
suspend fun reset() {
85+
dataStore.edit { it.remove(KEY_UPLOAD_POLICY) }
86+
}
87+
88+
// Fire-and-forget refresh, deduped so a burst of stale emissions launches at most one fetch.
89+
private fun triggerRefresh() {
90+
if (refreshing.compareAndSet(false, true)) {
91+
scope.launch {
92+
try {
93+
preloadPolicy()
94+
} finally {
95+
refreshing.set(false)
96+
}
97+
}
98+
}
99+
}
100+
101+
// A policy-driven denial means our cached policy let something through the server now rejects.
102+
// Re-fetch when the echoed version differs from what we cached (or we have nothing cached).
103+
private suspend fun refreshIfPolicyChanged(cause: Throwable) {
104+
val deniedVersion = when (cause) {
105+
is InitiateExternalUploadError.UnsupportedType -> cause.policyVersion
106+
is InitiateExternalUploadError.TooLarge -> cause.policyVersion
107+
else -> return
108+
}
109+
if (deniedVersion == null || deniedVersion != cached()?.version) {
110+
preloadPolicy()
111+
}
112+
}
113+
114+
private suspend fun persist(policy: UploadPolicy) {
115+
val raw = json.encodeToString(CachedUploadPolicy.fromDomain(policy, now()))
116+
dataStore.edit { it[KEY_UPLOAD_POLICY] = raw }
117+
}
118+
119+
private suspend fun cached(): CachedUploadPolicy? = dataStore.data.first().decode()
120+
121+
private fun Preferences.decode(): CachedUploadPolicy? =
122+
this[KEY_UPLOAD_POLICY]?.let { raw ->
123+
runCatching { json.decodeFromString<CachedUploadPolicy>(raw) }.getOrNull()
124+
}
125+
126+
private fun CachedUploadPolicy.isFresh(): Boolean = now() - fetchedAtMillis < ttlMillis
127+
128+
private fun now(): Long = System.currentTimeMillis()
129+
130+
companion object {
131+
private val KEY_UPLOAD_POLICY = stringPreferencesKey("cached_upload_policy")
132+
}
133+
}
134+
135+
/**
136+
* On-disk form. [UploadPolicy.ttl] is a [kotlin.time.Duration] (not kotlinx-serializable) so it is
137+
* stored as milliseconds; [fetchedAtMillis] stamps when it was cached so freshness can be checked
138+
* against the ttl; [MimeTypeConstraints] is already `@Serializable` and stored as-is.
139+
*/
140+
@kotlinx.serialization.Serializable
141+
private data class CachedUploadPolicy(
142+
val version: String,
143+
val ttlMillis: Long,
144+
val fetchedAtMillis: Long,
145+
val mimeTypeConstraints: List<MimeTypeConstraints>,
146+
) {
147+
fun toDomain(): UploadPolicy = UploadPolicy(
148+
version = version,
149+
ttl = ttlMillis.milliseconds,
150+
mimeTypeConstraints = mimeTypeConstraints,
151+
)
152+
153+
companion object {
154+
fun fromDomain(policy: UploadPolicy, fetchedAtMillis: Long): CachedUploadPolicy = CachedUploadPolicy(
155+
version = policy.version,
156+
ttlMillis = policy.ttl.inWholeMilliseconds,
157+
fetchedAtMillis = fetchedAtMillis,
158+
mimeTypeConstraints = policy.mimeTypeConstraints,
159+
)
160+
}
161+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.flipcash.app.blob
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import com.flipcash.libs.coroutines.DispatcherProvider
6+
import com.flipcash.services.controllers.BlobStorageController
7+
import com.flipcash.services.models.InitiateExternalUploadError
8+
import com.flipcash.services.models.blob.MimeTypeConstraints
9+
import com.flipcash.services.models.blob.UploadPolicy
10+
import io.mockk.coEvery
11+
import io.mockk.coVerify
12+
import io.mockk.mockk
13+
import kotlinx.coroutines.CoroutineDispatcher
14+
import kotlinx.coroutines.ExperimentalCoroutinesApi
15+
import kotlinx.coroutines.flow.first
16+
import kotlinx.coroutines.test.TestScope
17+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
18+
import kotlinx.coroutines.test.runTest
19+
import org.junit.runner.RunWith
20+
import org.robolectric.RobolectricTestRunner
21+
import kotlin.test.Test
22+
import kotlin.test.assertEquals
23+
import kotlin.time.Duration
24+
import kotlin.time.Duration.Companion.hours
25+
26+
@OptIn(ExperimentalCoroutinesApi::class)
27+
@RunWith(RobolectricTestRunner::class)
28+
class BlobStorageCoordinatorTest {
29+
30+
private val controller = mockk<BlobStorageController>()
31+
private val context = ApplicationProvider.getApplicationContext<Context>()
32+
33+
private fun TestScope.newCoordinator(): BlobStorageCoordinator {
34+
val test = UnconfinedTestDispatcher(testScheduler)
35+
val dispatchers = object : DispatcherProvider {
36+
override val Default: CoroutineDispatcher = test
37+
override val Main: CoroutineDispatcher = test
38+
override val IO: CoroutineDispatcher = test
39+
}
40+
return BlobStorageCoordinator(context, controller, dispatchers)
41+
}
42+
43+
private fun policy(version: String, ttl: Duration) =
44+
UploadPolicy(
45+
version = version,
46+
ttl = ttl,
47+
mimeTypeConstraints = listOf(MimeTypeConstraints("image/*", 1_000, null)),
48+
)
49+
50+
@Test
51+
fun `preloaded policy is served from the cache`() = runTest {
52+
coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours))
53+
val coordinator = newCoordinator()
54+
coordinator.reset()
55+
56+
coordinator.preloadPolicy()
57+
58+
assertEquals("v1", coordinator.policy.first()?.version)
59+
}
60+
61+
@Test
62+
fun `a policy-denied upload with a new version refreshes the cached policy`() = runTest {
63+
coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours))
64+
coEvery { controller.upload(any(), any()) } returns
65+
Result.failure(InitiateExternalUploadError.UnsupportedType(policyVersion = "v2"))
66+
val coordinator = newCoordinator()
67+
coordinator.reset()
68+
coordinator.preloadPolicy() // seeds v1 (getUploadPolicy #1)
69+
70+
coordinator.upload(byteArrayOf(1), "image/png")
71+
72+
// preload + a refresh, because the denied version (v2) differs from the cached one (v1).
73+
coVerify(exactly = 2) { controller.getUploadPolicy() }
74+
}
75+
76+
@Test
77+
fun `a policy-denied upload with the same version does not refresh`() = runTest {
78+
coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours))
79+
coEvery { controller.upload(any(), any()) } returns
80+
Result.failure(InitiateExternalUploadError.UnsupportedType(policyVersion = "v1"))
81+
val coordinator = newCoordinator()
82+
coordinator.reset()
83+
coordinator.preloadPolicy()
84+
85+
coordinator.upload(byteArrayOf(1), "image/png")
86+
87+
coVerify(exactly = 1) { controller.getUploadPolicy() }
88+
}
89+
90+
@Test
91+
fun `a non-policy upload failure does not refresh`() = runTest {
92+
coEvery { controller.getUploadPolicy() } returns Result.success(policy("v1", 1.hours))
93+
coEvery { controller.upload(any(), any()) } returns Result.failure(RuntimeException("network"))
94+
val coordinator = newCoordinator()
95+
coordinator.reset()
96+
coordinator.preloadPolicy()
97+
98+
coordinator.upload(byteArrayOf(1), "image/png")
99+
100+
coVerify(exactly = 1) { controller.getUploadPolicy() }
101+
}
102+
}

apps/flipcash/shared/session/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
testImplementation(testFixtures(project(":libs:coroutines")))
1313
testImplementation(testFixtures(project(":ui:resources")))
1414

15+
implementation(project(":apps:flipcash:shared:blob"))
1516
implementation(project(":apps:flipcash:shared:chat"))
1617
implementation(project(":apps:flipcash:shared:contacts"))
1718
implementation(project(":apps:flipcash:shared:activityfeed"))

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.flipcash.app.appsettings.AppSettingValue
66
import com.flipcash.app.appsettings.AppSettingsCoordinator
77
import com.flipcash.app.billing.BillingClient
88
import com.flipcash.app.contacts.ContactCoordinator
9+
import com.flipcash.app.blob.BlobStorageCoordinator
910
import com.flipcash.services.models.chat.ChatType
1011
import com.flipcash.shared.chat.ChatCoordinator
1112
import com.flipcash.app.core.internal.bill.BillController
@@ -100,6 +101,7 @@ class RealSessionController @Inject constructor(
100101
private val tokenCoordinator: TokenCoordinator,
101102
private val contactCoordinator: ContactCoordinator,
102103
private val chatCoordinator: ChatCoordinator,
104+
private val blobStorageCoordinator: BlobStorageCoordinator,
103105
networkObserver: NetworkConnectivityListener,
104106
featureFlagController: FeatureFlagController,
105107
appSettingsCoordinator: AppSettingsCoordinator,
@@ -200,6 +202,15 @@ class RealSessionController @Inject constructor(
200202
.onEach { count -> stateHolder.update { it.copy(contactDmUnreadCount = count) } }
201203
.launchIn(scope)
202204

205+
// Preload the blob upload policy once registered so profile-photo selection can filter and
206+
// validate against it without a network round-trip. Cached in the BlobStorageCoordinator.
207+
userManager.state
208+
.map { it.authState }
209+
.filter { it.isAtLeastRegistered }
210+
.distinctUntilChanged()
211+
.onEach { blobStorageCoordinator.preloadPolicy() }
212+
.launchIn(scope)
213+
203214
appSettingsCoordinator
204215
.observeValue(AppSettingValue.CameraStartByDefault)
205216
.onEach { autoStart -> stateHolder.update { it.copy(autoStartCamera = autoStart) } }

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ include(
8282
":apps:flipcash:shared:tokens:core",
8383
":apps:flipcash:shared:theme",
8484
":apps:flipcash:shared:profile",
85+
":apps:flipcash:shared:blob",
8586
":apps:flipcash:shared:userflags",
8687
":apps:flipcash:shared:workers",
8788
":apps:flipcash:shared:web",

0 commit comments

Comments
 (0)