Skip to content
Merged
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 apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:notifications"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:onramp:coinbase"))
implementation(project(":apps:flipcash:shared:onramp:deeplinks"))
implementation(libs.phantom.connect) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import coil3.request.crossfade
import com.flipcash.app.auth.AuthManager
import okio.Path.Companion.toOkioPath
import com.flipcash.app.core.android.ActivityProvider
import com.flipcash.shared.chat.media.ChatPhotoFetcher
import com.flipcash.shared.chat.media.ChatPhotoKeyer
import com.flipcash.app.currency.PreferredCurrencyController
import com.flipcash.app.bills.share.TipCodePreviewCache
import com.getcode.opencode.repositories.EventRepository
Expand Down Expand Up @@ -51,6 +53,9 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
@Inject
lateinit var preferredCurrencyController: Lazy<PreferredCurrencyController>

@Inject
lateinit var chatPhotoFetcher: Lazy<ChatPhotoFetcher.Factory>

@Inject
lateinit var workerFactory: Lazy<HiltWorkerFactory>

Expand Down Expand Up @@ -109,6 +114,8 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
// would flash the BlurHash again). The default strategy respects HTTP cache headers and
// would revalidate/re-fetch the ephemeral, expiring download URLs; blobs are static.
.components {
add(ChatPhotoKeyer())
add(chatPhotoFetcher.get())
add(OkHttpNetworkFetcherFactory(cacheStrategy = { ImmutableBlobCacheStrategy }))
}
.build()
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/shared/blob/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)
// The BlurHash round-trip test decodes with the real decoder; common-ui is compose-versioned.
testImplementation(platform(libs.compose.bom))
testImplementation(project(":apps:flipcash:shared:common-ui"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import androidx.datastore.preferences.preferencesDataStoreFile
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.controllers.BlobStorageController
import com.flipcash.services.models.InitiateExternalUploadError
import com.flipcash.services.models.blob.EncryptedConstraints
import com.flipcash.services.models.blob.MimeTypeConstraints
import com.flipcash.services.models.blob.UploadPolicy
import com.flipcash.services.models.chat.BlobId
import com.flipcash.services.models.chat.ChatId
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -75,12 +78,75 @@ class BlobStorageCoordinator @Inject constructor(
* poll are all handled inside the controller. A policy-driven rejection invalidates the cached
* policy (the server echoes a newer policy version on such denials).
*/
suspend fun upload(bytes: ByteArray, mimeType: String): Result<BlobId> {
val result = blobStorageController.upload(bytes, mimeType)
suspend fun upload(
bytes: ByteArray,
mimeType: String,
onProgress: ((sentBytes: Long, totalBytes: Long) -> Unit)? = null,
): Result<BlobId> {
val result = blobStorageController.upload(bytes, mimeType, onProgress)
result.exceptionOrNull()?.let { refreshIfPolicyChanged(it) }
return result
}

/**
* Stores an encoded chat photo and returns its READY [BlobId], retrying what a retry can fix.
*
* [jpeg] is the encoder's output. With [sealing] it is encrypted for that chat and uploaded as
* opaque bytes; without, it goes up as plain `image/jpeg` for the server to moderate. Each
* attempt reserves afresh, so a retry gets a new blob id and re-seals under it.
*
* Up to [ChatMediaRetry.BACKOFFS].size retries, 1 s, 2 s, then 4 s apart, for failures in
* transit or while finalizing; a refusal that repeating can't change (see
* [ChatMediaRetry.isRetryable]) is returned at once. [onProgress] restarts from zero on a retry.
*/
suspend fun storeChatMedia(
jpeg: ByteArray,
sealing: ChatMediaSealing? = null,
onProgress: ((sentBytes: Long, totalBytes: Long) -> Unit)? = null,
): Result<BlobId> = withStoreRetries { _ ->
if (sealing != null) {
blobStorageController.uploadSealed(jpeg, sealing.chatId, sealing.seal, onProgress)
} else {
blobStorageController.uploadChatMedia(jpeg, ChatMediaEncoder.UPLOAD_MIME_TYPE, onProgress)
}
}

/**
* [storeChatMedia] up to the point the bytes are in storage, without waiting for finalization.
* Retries only what happens before that, so a returned id means "stored": a caller that then
* fails to see it finalize re-polls it with [awaitChatMediaReady] instead of uploading again.
* [onAttempt] runs before each attempt, first included, so progress can restart with a retry.
*/
suspend fun storeChatMediaUnfinalized(
jpeg: ByteArray,
sealing: ChatMediaSealing? = null,
onAttempt: (() -> Unit)? = null,
onProgress: ((sentBytes: Long, totalBytes: Long) -> Unit)? = null,
): Result<BlobId> = withStoreRetries { _ ->
onAttempt?.invoke()
if (sealing != null) {
blobStorageController.storeSealed(jpeg, sealing.chatId, sealing.seal, onProgress)
} else {
blobStorageController.storeChatMedia(jpeg, ChatMediaEncoder.UPLOAD_MIME_TYPE, onProgress)
}
}

/** Waits for a blob [storeChatMediaUnfinalized] stored to finalize. */
suspend fun awaitChatMediaReady(blobId: BlobId): Result<BlobId> =
blobStorageController.awaitChatMediaReady(blobId)

private suspend fun withStoreRetries(attemptStore: suspend (attempt: Int) -> Result<BlobId>): Result<BlobId> {
var attempt = 0
while (true) {
val result = attemptStore(attempt)
val failure = result.exceptionOrNull() ?: return result

refreshIfPolicyChanged(failure)
if (!ChatMediaRetry.isRetryable(failure) || attempt >= ChatMediaRetry.BACKOFFS.size) return result
delay(ChatMediaRetry.BACKOFFS[attempt++])
}
}

suspend fun reset() {
dataStore.edit { it.remove(KEY_UPLOAD_POLICY) }
}
Expand Down Expand Up @@ -128,26 +194,32 @@ class BlobStorageCoordinator @Inject constructor(
private fun now(): Long = System.currentTimeMillis()

companion object {
private val KEY_UPLOAD_POLICY = stringPreferencesKey("cached_upload_policy")
// v2: entries cached before the policy carried `encrypted` would read back as "encrypted
// uploads not allowed" for the rest of their ttl; a new key makes them refetch instead.
private val KEY_UPLOAD_POLICY = stringPreferencesKey("cached_upload_policy_v2")
}
}

/**
* On-disk form. [UploadPolicy.ttl] is a [kotlin.time.Duration] (not kotlinx-serializable) so it is
* stored as milliseconds; [fetchedAtMillis] stamps when it was cached so freshness can be checked
* against the ttl; [MimeTypeConstraints] is already `@Serializable` and stored as-is.
* against the ttl; [MimeTypeConstraints] and [EncryptedConstraints] are already `@Serializable` and stored as-is.
*/
@kotlinx.serialization.Serializable
private data class CachedUploadPolicy(
val version: String,
val ttlMillis: Long,
val fetchedAtMillis: Long,
val mimeTypeConstraints: List<MimeTypeConstraints>,
// Absent in entries cached before encrypted uploads existed, which read back as "not allowed"
// until the next refresh.
val encrypted: EncryptedConstraints? = null,
) {
fun toDomain(): UploadPolicy = UploadPolicy(
version = version,
ttl = ttlMillis.milliseconds,
mimeTypeConstraints = mimeTypeConstraints,
encrypted = encrypted,
)

companion object {
Expand All @@ -156,6 +228,7 @@ private data class CachedUploadPolicy(
ttlMillis = policy.ttl.inWholeMilliseconds,
fetchedAtMillis = fetchedAtMillis,
mimeTypeConstraints = policy.mimeTypeConstraints,
encrypted = policy.encrypted,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.flipcash.app.blob

import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sign

/**
* Encoder for [BlurHash](https://blurha.sh) strings, the counterpart of the decoder in
* `shared/common-ui`. A sealed photo can't be previewed from the server, so its hash is computed
* here from the plaintext and travels inside the encrypted message.
*
* Works on an ARGB [IntArray] so the maths runs on the JVM; [fromThumbnail] is the only part that
* needs a [android.graphics.Bitmap].
*/
object BlurHashEncoder {

private const val THUMBNAIL_EDGE = 64

/**
* Hash of [pixels] (row-major ARGB, alpha ignored) using [componentsX] × [componentsY] DCT
* components. Returns "" when the input is unusable; a missing preview must never fail a send.
*/
fun encode(pixels: IntArray, width: Int, height: Int, componentsX: Int, componentsY: Int): String {
if (width <= 0 || height <= 0 || pixels.size < width * height) return ""
if (componentsX !in 1..9 || componentsY !in 1..9) return ""

val factors = Array(componentsX * componentsY) { index ->
factor(pixels, width, height, index % componentsX, index / componentsX)
}
val dc = factors[0]
val ac = factors.drop(1)

val hash = StringBuilder()
hash.append(encode83((componentsX - 1) + (componentsY - 1) * 9, 1))

val maxValue: Float
if (ac.isNotEmpty()) {
val actualMax = ac.maxOf { channels -> channels.maxOf { abs(it) } }
val quantisedMax = floor(actualMax * 166f - 0.5f).toInt().coerceIn(0, 82)
maxValue = (quantisedMax + 1) / 166f
hash.append(encode83(quantisedMax, 1))
} else {
maxValue = 1f
hash.append(encode83(0, 1))
}

hash.append(encode83(encodeDc(dc), 4))
for (factor in ac) hash.append(encode83(encodeAc(factor, maxValue), 2))
return hash.toString()
}

/**
* Hash of an image of [width] × [height] from a [THUMBNAIL_EDGE]-px thumbnail of [source],
* 4×3 components for landscape (and square) and 3×4 for portrait. "" on any failure.
*/
fun fromThumbnail(source: android.graphics.Bitmap, width: Int, height: Int): String =
runCatching {
val scale = min(1f, THUMBNAIL_EDGE.toFloat() / max(source.width, source.height))
val tw = max((source.width * scale).toInt(), 1)
val th = max((source.height * scale).toInt(), 1)
val thumb = android.graphics.Bitmap.createScaledBitmap(source, tw, th, true)
val pixels = IntArray(tw * th)
thumb.getPixels(pixels, 0, tw, 0, 0, tw, th)
if (thumb !== source) thumb.recycle()
val landscape = width >= height
encode(pixels, tw, th, if (landscape) 4 else 3, if (landscape) 3 else 4)
}.getOrDefault("")

private fun factor(pixels: IntArray, width: Int, height: Int, i: Int, j: Int): FloatArray {
var r = 0f
var g = 0f
var b = 0f
val normalisation = if (i == 0 && j == 0) 1f else 2f
for (y in 0 until height) {
val basisY = cos(PI * j * y / height).toFloat()
for (x in 0 until width) {
val basis = normalisation * cos(PI * i * x / width).toFloat() * basisY
val pixel = pixels[y * width + x]
r += basis * srgbToLinear(pixel shr 16 and 255)
g += basis * srgbToLinear(pixel shr 8 and 255)
b += basis * srgbToLinear(pixel and 255)
}
}
val scale = 1f / (width * height)
return floatArrayOf(r * scale, g * scale, b * scale)
}

private fun encodeDc(value: FloatArray): Int =
(linearToSrgb(value[0]) shl 16) or (linearToSrgb(value[1]) shl 8) or linearToSrgb(value[2])

private fun encodeAc(value: FloatArray, maxValue: Float): Int {
fun quantise(v: Float): Int =
floor(signPow(v / maxValue, 0.5f) * 9f + 9.5f).toInt().coerceIn(0, 18)
return quantise(value[0]) * 19 * 19 + quantise(value[1]) * 19 + quantise(value[2])
}

private fun signPow(value: Float, exp: Float): Float = abs(value).pow(exp) * sign(value)

private fun srgbToLinear(value: Int): Float {
val v = value / 255f
return if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).pow(2.4f)
}

private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
val srgb = if (v <= 0.0031308f) v * 12.92f else 1.055f * v.pow(1f / 2.4f) - 0.055f
return (srgb * 255f + 0.5f).toInt()
}

private fun encode83(value: Int, length: Int): String {
val out = CharArray(length)
var remaining = value
for (i in length - 1 downTo 0) {
out[i] = CHARS[remaining % 83]
remaining /= 83
}
return String(out)
}

private const val CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.flipcash.app.blob

/** Picks which upload-policy entry governs a MIME type. */
object ChatMediaConstraints {

/**
* Index of the first entry in [patterns], in policy order, that matches [mimeType]: the
* catch-all pattern matches anything, a type wildcard matches on the type alone, and anything
* else is an exact `type/subtype`.
* Null when [mimeType] has no `/` or nothing matches.
*
* Policy order is authoritative — a later, more specific entry never overrides an earlier
* catch-all. Must agree with iOS (`test-vectors/chat_media.json`, `constraintSelection`).
*/
fun firstMatchIndex(patterns: List<String>, mimeType: String): Int? {
val slash = mimeType.indexOf('/')
if (slash < 0) return null
val type = mimeType.substring(0, slash)

val index = patterns.indexOfFirst { pattern ->
when {
pattern == "*/*" -> true
pattern.endsWith("/*") -> pattern.dropLast(2).equals(type, ignoreCase = true)
else -> pattern.equals(mimeType, ignoreCase = true)
}
}
return index.takeIf { it >= 0 }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.flipcash.app.blob

import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

data class PixelSize(val width: Int, val height: Int)

/**
* The pixel size a photo is scaled to before encoding. A bound of 0 means unbounded, and an image
* is never scaled up.
*/
object ChatMediaDownscale {

/**
* [width] and [height] are display-space — after the EXIF rotation has been applied.
*
* The scale is computed in doubles and truncated, which can land a pixel or two over
* [maxPixels], so the longer side is then walked down until the area fits. Must agree with iOS
* (`test-vectors/chat_media.json`, `downscale`) to the pixel.
*/
fun target(width: Int, height: Int, maxWidth: Int, maxHeight: Int, maxPixels: Long): PixelSize {
if (width <= 0 || height <= 0) return PixelSize(max(width, 1), max(height, 1))

var scale = 1.0
if (maxWidth > 0) scale = min(scale, maxWidth.toDouble() / width)
if (maxHeight > 0) scale = min(scale, maxHeight.toDouble() / height)
if (maxPixels > 0) scale = min(scale, sqrt(maxPixels.toDouble() / (width.toDouble() * height)))

var w = max((width * scale).toInt(), 1)
var h = max((height * scale).toInt(), 1)

if (maxPixels > 0) {
while (w.toLong() * h > maxPixels && (w > 1 || h > 1)) {
if (w >= h) w-- else h--
}
}
return PixelSize(w, h)
}
}
Loading
Loading