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
2 changes: 2 additions & 0 deletions apps/flipcash/app/src/main/res/xml/file_paths.xml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,6 @@
<files-path name="traces" path="traces/" />
<!-- Rendered tip-code Sharesheet previews (cacheDir/share_previews/). -->
<cache-path name="share_previews" path="share_previews/" />
<!-- Exported tip codes, PNG + SVG (cacheDir/share_exports/). -->
<cache-path name="share_exports" path="share_exports/" />
</paths>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
package com.flipcash.app.core.share

import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File

/**
* A user-facing export of a tip code: the scannable graphic on its own, written to a file the
* Sharesheet (or a "save to Files" target) can consume.
*
* Distinct from [TipCodePreview], which exists only to give the Sharesheet a thumbnail while the
* thing actually shared stays the tip URL. An export *is* the payload.
*/
data class TipCodeExport(
val uri: Uri,
val format: TipCodeExportFormat,
) {
val mimeType: String get() = format.mimeType
}

enum class TipCodeExportFormat(val extension: String, val mimeType: String) {
/** Raster. Universally pasteable; fixed resolution. */
Png("png", "image/png"),

/**
* Vector. Scales to any size (print, large-format) and stays a few KB.
*
* Self-contained by construction — no fonts, no embedded raster, no external references —
* because the export is code-only, so there is no text to worry about.
*/
Svg("svg", "image/svg+xml"),
}

/**
* Where exported codes live on disk and how they map to `content://` URIs.
*
* Separate directory from [TipCodePreviewStorage] so the preview cache's aggressive pruning can't
* delete an export out from under a share in progress, and so the two can be tuned independently.
* Files are named by the card's content [tipCodePreviewSignature], so re-exporting the same card
* overwrites rather than accumulates.
*/
object TipCodeExportStorage {
const val SUBDIR = "share_exports"

fun dir(context: Context): File =
File(context.cacheDir, SUBDIR).apply { if (!exists()) mkdirs() }

/**
* Name is user-visible in some share targets ("save to Files"), hence the readable prefix
* rather than a bare hash.
*/
fun file(context: Context, signature: String, format: TipCodeExportFormat): File =
File(dir(context), "flipcash-code-$signature.${format.extension}")

fun uriFor(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, TipCodePreviewStorage.authority(context), file)

/** See [TipCodePreviewStorage.prune]; exports are re-creatable, so over-pruning only costs a re-render. */
fun prune(
context: Context,
maxTotalBytes: Long = DEFAULT_MAX_TOTAL_BYTES,
maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
now: Long = System.currentTimeMillis(),
) = pruneDirectory(dir(context), maxTotalBytes, maxAge, now)

private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
package com.flipcash.app.core.share

import android.content.Context
import android.graphics.Bitmap
import androidx.core.content.ContextCompat
import com.flipcash.core.R
import com.flipcash.app.core.bill.Scannable
import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.codes.kikcode.KikCodeSvg
import com.getcode.codes.kikcode.kikCodeBitmap
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton

/**
* Exports a tip card's scannable code as a PNG or an SVG.
*
* Code-only, matching what the share preview already shows: no display name, no avatar, no card
* chrome. That keeps the SVG honest (nothing to embed a font for) and makes both formats render the
* same thing.
*
* Both formats come off the *same* shared geometry (`:libs:codes:kikcode`), which is also what the
* on-screen `KikCodeContentView` and iOS draw — so an exported file and the code the user is looking
* at cannot disagree. Rasterisation stays native (Android [Bitmap] here, `CGContext` on iOS); only
* the maths is shared.
*/
@Singleton
class TipCodeExporter @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatchers: DispatcherProvider,
) {

/**
* Writes [card]'s code in [format] and returns a `content://` handle to it, or `null` if the
* write failed — callers should degrade (share the URL alone) rather than surface an error.
*
* [sizePx] applies to [TipCodeExportFormat.Png] only; the SVG carries a `viewBox` and scales.
*/
suspend fun export(
card: Scannable.TipCard,
format: TipCodeExportFormat,
sizePx: Int = DEFAULT_PNG_SIZE_PX,
): TipCodeExport? = withContext(dispatchers.IO) {
val payload = card.data.toByteArray()
if (payload.isEmpty()) return@withContext null

runCatching {
val file = TipCodeExportStorage.file(
context = context,
signature = tipCodePreviewSignature(card),
format = format,
)
when (format) {
TipCodeExportFormat.Png -> writePng(payload, sizePx, file)
TipCodeExportFormat.Svg -> writeSvg(payload, file)
}
TipCodeExport(uri = TipCodeExportStorage.uriFor(context, file), format = format)
}.getOrNull()
}

private fun writePng(payload: ByteArray, sizePx: Int, file: File) {
val badge = ContextCompat.getDrawable(context, R.drawable.ic_logo_round_white)
val bitmap = kikCodeBitmap(payload = payload, size = sizePx, badge = badge)
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out)
}
} finally {
bitmap.recycle()
}
}

private fun writeSvg(payload: ByteArray, file: File) {
file.writeText(KikCodeSvg.render(payload))
}

private companion object {
/**
* Large enough that the code stays crisp when a share target scales it up, small enough to
* stay well under a megabyte -- the graphic is flat white on transparent, so PNG compresses
* it hard.
*/
const val DEFAULT_PNG_SIZE_PX = 1024

// PNG is lossless; the parameter only controls compression effort.
const val PNG_QUALITY = 100
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,20 +45,27 @@ object TipCodePreviewStorage {
maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
now: Long = System.currentTimeMillis(),
) {
runCatching {
val files = dir(context).listFiles()?.sortedByDescending { it.lastModified() }
?: return
var kept = 0L
for (file in files) {
val expired = now - file.lastModified() > maxAge
kept += file.length()
if (expired || kept > maxTotalBytes) {
file.delete()
}
}
}
pruneDirectory(dir(context), maxTotalBytes, maxAge, now)
}

private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
}

/**
* Deletes anything in [dir] older than [maxAge], then -- newest first -- keeps files until
* [maxTotalBytes] is reached and deletes the rest. Never throws; cleanup is best-effort.
*/
internal fun pruneDirectory(dir: File, maxTotalBytes: Long, maxAge: Long, now: Long) {
runCatching {
val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
var kept = 0L
for (file in files) {
val expired = now - file.lastModified() > maxAge
kept += file.length()
if (expired || kept > maxTotalBytes) {
file.delete()
}
}
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
package com.flipcash.app.core.share

import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import com.getcode.codes.kikcode.kikCodeBitmap
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.math.hypot
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Covers the Android *painter* — the half of code rendering that the cross-platform vectors can't
* reach. `:libs:codes:kikcode` gates the geometry and the SVG on both toolchains; these assertions
* gate the translation of that geometry into `Canvas` draw calls, where an inverted arc sweep or a
* mis-scaled badge would be invisible to the vectors.
*
* Native graphics so `Canvas` actually rasterises rather than recording no-ops.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class KikCodePainterTest {

/**
* The full 35-byte payload with every bit set -> all six rings full, i.e. the largest graphic
* the geometry can produce. A 20-byte tip payload leaves the outermost ring empty (24 bytes of
* finder+payload is 192 bits, and ring 5 starts at bit 240), so it can't measure the framing.
*/
private val saturated = ByteArray(35) { 0xFF.toByte() }

/** Irregular, so runs, isolated dots and gaps all appear. */
private val scattered = ByteArray(20) { (it * 7 + 1).toByte() }

@Test
fun `renders marks`() {
val bitmap = kikCodeBitmap(scattered, SIZE)
assertEquals(SIZE, bitmap.width)
assertEquals(SIZE, bitmap.height)
assertTrue(bitmap.opaquePixelCount() > 0, "nothing was drawn")
}

@Test
fun `graphic fills its box without overflowing it`() {
// The outermost stroke reaches ~0.939 of the radius by construction (ring centre 0.90625
// plus half a 0.0656 stroke). Both bounds matter: the upper one catches clipping, and the
// lower one catches a re-introduced inset -- Android used to shrink the code to 0.93 and
// then overscan the view by 1.03 to compensate, which put it ~4% under iOS.
val extent = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / (SIZE / 2.0)
assertTrue(extent in 0.93..0.96, "outer extent was $extent of the radius")
}

@Test
fun `centre well is left empty when there is no badge`() {
val bitmap = kikCodeBitmap(scattered, SIZE)
assertEquals(Color.TRANSPARENT, bitmap.getPixel(SIZE / 2, SIZE / 2))
}

@Test
fun `badge is drawn into the centre well`() {
val bitmap = kikCodeBitmap(scattered, SIZE, badge = ColorDrawable(Color.RED))
assertEquals(Color.RED, bitmap.getPixel(SIZE / 2, SIZE / 2))

// The well is INNER_RING_RATIO (0.32) of the outer radius, and the badge fills it exactly.
val expected = (SIZE / 2.0) * 0.32
val actual = bitmap.run {
var left = width
for (x in 0 until width) {
if (getPixel(x, height / 2) == Color.RED) { left = x; break }
}
width / 2.0 - left
}
assertTrue(kotlin.math.abs(actual - expected) <= 1.0, "badge half-width $actual, want $expected")
}

@Test
fun `output scales linearly with size`() {
val small = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / SIZE
val large = kikCodeBitmap(saturated, SIZE * 2).maxOpaqueRadius() / (SIZE * 2)
assertTrue(kotlin.math.abs(small - large) < 0.005, "extent $small vs $large")
}

private fun Bitmap.pixels(): IntArray =
IntArray(width * height).also { getPixels(it, 0, width, 0, 0, width, height) }

private fun Bitmap.opaquePixelCount(): Int = pixels().count { Color.alpha(it) > ALPHA_FLOOR }

/** Distance from the centre to the furthest drawn pixel, in px. */
private fun Bitmap.maxOpaqueRadius(): Double {
val centre = width / 2.0
val pixels = pixels()
var furthest = 0.0
for (index in pixels.indices) {
if (Color.alpha(pixels[index]) <= ALPHA_FLOOR) continue
val distance = hypot(index % width - centre, (index / width) - centre)
if (distance > furthest) furthest = distance
}
return furthest
}

private companion object {
const val SIZE = 512

// Ignore antialiasing fringes so the extent measurement tracks the shape, not its feather.
const val ALPHA_FLOOR = 128
}
}
2 changes: 2 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ kotlin {
it.binaries.framework {
baseName = "SharedCore"
isStatic = true
export(project(":libs:codes:kikcode"))
export(project(":libs:encryption:base58"))
export(project(":libs:encryption:sha256"))
export(project(":libs:encryption:sha512"))
Expand All@@ -31,6 +32,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
api(project(":libs:codes:kikcode"))
api(project(":libs:encryption:base58"))
api(project(":libs:encryption:sha256"))
api(project(":libs:encryption:sha512"))
Expand Down
1 change: 1 addition & 0 deletions libs/codes/kikcode/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
/build
Loading
Loading