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
6 changes: 4 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
// cachedAt is non-null only when data was served from OfflineCache rather than fetched
// live, so callers can surface how stale it is (e.g. VaultViewModel/OfflineBanner).
data class Success<T>(val data: T, val cachedAt: Long? = null) : ApiResult<T>()
data class Error(val message: String, val code: Int = 0) : ApiResult<Nothing>()
object NetworkUnavailable : ApiResult<Nothing>()
}
Expand Down Expand Up @@ -150,7 +152,7 @@ class ApiClient(
ensureFreshToken()
if (!networkMonitor.isConnected) {
val cached = offlineCache.load(path)
return if (cached != null) ApiResult.Success(Json.decodeFromString(cached))
return if (cached != null) ApiResult.Success(Json.decodeFromString(cached.data), cachedAt = cached.timestamp)
else ApiResult.NetworkUnavailable
}
return runCatching {
Expand Down
72 changes: 70 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/api/Infrastructure.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.ethosprotocol.models.AuthToken
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import java.time.Duration
Expand All @@ -25,20 +27,86 @@ class NetworkMonitor @Inject constructor(@ApplicationContext private val context
}
}

// Wraps a cached response with the wall-clock time it was written, so callers can tell how
// stale the data is instead of presenting it as unconditionally "current".
@Serializable
data class CacheEnvelope(val timestamp: Long, val data: String)

@Singleton
class OfflineCache @Inject constructor(@ApplicationContext private val context: Context) {
private val dir = File(context.cacheDir, "ttl_offline").also { it.mkdirs() }

// Byte cap for the cache directory's total size; least-recently-used entries are evicted
// once a save() pushes the directory over this cap. Exposed as `internal var` (rather than
// a constructor param) so tests can shrink it to force eviction deterministically without
// fighting Hilt's @Inject constructor resolution.
internal var maxCacheBytes: Long = DEFAULT_MAX_CACHE_BYTES

// Tracks access recency in-memory (accessOrder = true keeps the most-recently-used entry at
// the tail on both get and put). Filesystem mtime is deliberately not used for LRU ordering
// since its resolution varies across filesystems/devices and would make eviction order
// unpredictable; this only needs to be accurate for the current process's lifetime.
private val accessOrder = Collections.synchronizedMap(
object : LinkedHashMap<String, Unit>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.Entry<String, Unit>) = false
}
)

init {
dir.listFiles()?.sortedBy { it.lastModified() }?.forEach { accessOrder[it.name] = Unit }
}

fun save(key: String, json: String) {
File(dir, key.sha256()).writeText(json)
val fileName = key.sha256()
val envelope = CacheEnvelope(timestamp = System.currentTimeMillis(), data = json)
File(dir, fileName).writeText(Json.encodeToString(CacheEnvelope.serializer(), envelope))
touch(fileName)
evictIfNeeded()
}

fun load(key: String): CacheEnvelope? {
val fileName = key.sha256()
val envelope = runCatching {
Json.decodeFromString(CacheEnvelope.serializer(), File(dir, fileName).readText())
}.getOrNull()
if (envelope != null) touch(fileName)
return envelope
}

fun load(key: String): String? = runCatching { File(dir, key.sha256()).readText() }.getOrNull()
// Wipes every cached entry, e.g. on sign-out so the next user's device doesn't retain a
// previous account's vault data offline.
fun clear() {
dir.listFiles()?.forEach { it.delete() }
accessOrder.clear()
}

private fun touch(fileName: String) {
accessOrder[fileName] = Unit
}

private fun evictIfNeeded() {
var totalSize = dir.listFiles()?.sumOf { it.length() } ?: 0L
if (totalSize <= maxCacheBytes) return
val leastRecentlyUsed = synchronized(accessOrder) { accessOrder.keys.toList() }
for (fileName in leastRecentlyUsed) {
if (totalSize <= maxCacheBytes) break
val file = File(dir, fileName)
if (file.exists()) {
totalSize -= file.length()
file.delete()
}
accessOrder.remove(fileName)
}
}

private fun String.sha256(): String {
val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}

companion object {
const val DEFAULT_MAX_CACHE_BYTES = 5L * 1024 * 1024 // 5 MB
}
}

@Singleton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,39 @@ class NotificationHelper @Inject constructor(@ApplicationContext private val con
const val QUEUED_CHANNEL_ID = "ttl_queued"
const val QUEUED_CHANNEL_NAME = "Queued Requests"
const val QUEUED_NOTIFICATION_ID = 9_001

// Reserved range for per-vault notification IDs, kept clear of QUEUED_NOTIFICATION_ID
// and NO_VAULT_NOTIFICATION_ID below.
const val VAULT_NOTIFICATION_ID_RANGE_START = 10_000
// Fallback ID for the (practically unused) case where show() is called without a
// vaultId — sits below the reserved vault range so it can never collide with it.
const val NO_VAULT_NOTIFICATION_ID = 1

private const val VAULT_NOTIFICATION_IDS_PREFS = "vault_notification_ids"
}

// String.hashCode() collides between distinct vault IDs within the 32-bit hash space, which
// would make one vault's notification silently replace another's. Instead, persist a stable
// assignment of each vault ID to the next free slot in a reserved ID range — two distinct
// vault IDs are then guaranteed distinct notification IDs for as long as the mapping lives,
// rather than merely "unlikely" to collide.
private val vaultNotificationIdPrefs =
context.getSharedPreferences(VAULT_NOTIFICATION_IDS_PREFS, Context.MODE_PRIVATE)

init {
createChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH)
createChannel(QUEUED_CHANNEL_ID, QUEUED_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
}

@Synchronized
fun notificationIdFor(vaultId: String?): Int {
if (vaultId == null) return NO_VAULT_NOTIFICATION_ID
vaultNotificationIdPrefs.getInt(vaultId, -1).takeIf { it != -1 }?.let { return it }
val id = VAULT_NOTIFICATION_ID_RANGE_START + vaultNotificationIdPrefs.all.size
vaultNotificationIdPrefs.edit().putInt(vaultId, id).apply()
return id
}

fun show(title: String, body: String, vaultId: String?) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
Expand All @@ -42,10 +68,14 @@ class NotificationHelper @Inject constructor(@ApplicationContext private val con
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Groups all of a vault's notifications together so they visually cluster even if
// notificationIdFor() were ever wrong, rather than relying solely on ID uniqueness
// for replace-vs-append behavior.
.setGroup(vaultId ?: "general")
.build()

val nm = context.getSystemService(NotificationManager::class.java)
nm.notify(vaultId.hashCode(), notification)
nm.notify(notificationIdFor(vaultId), notification)
}

fun showQueuedActions(count: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.ethosprotocol.BuildConfig
import com.ethosprotocol.api.ApiClient
import com.ethosprotocol.api.ApiErrorMapper
import com.ethosprotocol.api.ApiResult
import com.ethosprotocol.api.OfflineCache
import com.ethosprotocol.api.TokenProvider
import com.ethosprotocol.models.*
import com.ethosprotocol.models.CreateVaultRequest
Expand Down
22 changes: 20 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,12 @@ private fun CheckInConfirmationDialog(vault: Vault, onConfirm: () -> Unit, onDis
}

@Composable
private fun OfflineBanner() {
private fun OfflineBanner(cachedAt: Long? = null) {
val message = if (cachedAt != null) {
"Offline — showing cached data (as of ${formatCacheAge(cachedAt)} ago)"
} else {
"Offline — showing cached data"
}
Surface(color = MaterialTheme.colorScheme.tertiaryContainer) {
// mergeDescendants groups the icon + label into a single TalkBack stop instead of two,
// so giving the icon a description adds context without a duplicate announcement.
Expand All @@ -383,12 +388,25 @@ private fun OfflineBanner() {
Icon(Icons.Default.WifiOff, contentDescription = "Offline",
tint = MaterialTheme.colorScheme.onTertiaryContainer)
Spacer(Modifier.width(8.dp))
Text("Offline — showing cached data", color = MaterialTheme.colorScheme.onTertiaryContainer,
Text(message, color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.bodySmall)
}
}
}

private fun formatCacheAge(cachedAt: Long): String {
val elapsedSeconds = ((System.currentTimeMillis() - cachedAt) / 1000).coerceAtLeast(0)
val minutes = elapsedSeconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
days > 0 -> "${days}d"
hours > 0 -> "${hours}h"
minutes > 0 -> "${minutes}m"
else -> "a few seconds"
}
}

@Composable
private fun VaultCard(
vault: Vault,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.ethosprotocol

import android.content.Context
import android.content.SharedPreferences
import com.ethosprotocol.services.NotificationHelper
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test

class NotificationHelperTest {

private lateinit var helper: NotificationHelper

@Before
fun setup() {
val context: Context = mockk(relaxed = true)
every { context.getSharedPreferences(any(), any()) } returns fakeSharedPreferences()
helper = NotificationHelper(context)
}

@Test
fun `same vault id always maps to the same notification id`() {
val first = helper.notificationIdFor("vault-1")
val second = helper.notificationIdFor("vault-1")

assertEquals(first, second)
}

@Test
fun `two different vault ids never collide across a large sample`() {
val ids = (0 until 5_000).map { helper.notificationIdFor("vault-$it") }

assertEquals(ids.size, ids.toSet().size)
}

@Test
fun `vault notification ids never collide with the queued check-in id`() {
val ids = (0 until 100).map { helper.notificationIdFor("vault-$it") }

assertFalse(ids.contains(NotificationHelper.QUEUED_NOTIFICATION_ID))
}

@Test
fun `null vault id does not collide with a real vault id`() {
val nullId = helper.notificationIdFor(null)
val realId = helper.notificationIdFor("vault-1")

assertNotEquals(nullId, realId)
}

private fun fakeSharedPreferences(): SharedPreferences {
val backing = mutableMapOf<String, Int>()
val editor: SharedPreferences.Editor = mockk(relaxed = true)
val prefs: SharedPreferences = mockk()

every { prefs.getInt(any(), any()) } answers { backing[firstArg()] ?: secondArg() }
every { prefs.all } answers { backing.toMutableMap() }
every { prefs.edit() } returns editor
every { editor.putInt(any(), any()) } answers {
backing[firstArg()] = secondArg()
editor
}
every { editor.apply() } just Runs

return prefs
}
}
103 changes: 103 additions & 0 deletions android/app/src/test/java/com/ethosprotocol/OfflineCacheTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.ethosprotocol

import android.content.Context
import com.ethosprotocol.api.OfflineCache
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

class OfflineCacheTest {

@get:Rule
val tempFolder = TemporaryFolder()

private fun newCache(): OfflineCache {
val context: Context = mockk()
every { context.cacheDir } returns tempFolder.root
return OfflineCache(context)
}

@Test
fun `save then load returns the original payload`() {
val cache = newCache()

cache.save("/vaults", "{\"a\":1}")
val loaded = cache.load("/vaults")

assertEquals("{\"a\":1}", loaded?.data)
}

@Test
fun `load returns null for a key that was never saved`() {
assertNull(newCache().load("/missing"))
}

@Test
fun `save records a timestamp close to the write time`() {
val cache = newCache()

val before = System.currentTimeMillis()
cache.save("/vaults", "{}")
val after = System.currentTimeMillis()
val loaded = cache.load("/vaults")!!

assertTrue(loaded.timestamp in before..after)
}

@Test
fun `distinct keys are cached independently`() {
val cache = newCache()

cache.save("/vaults", "{\"list\":[]}")
cache.save("/vaults/v1", "{\"id\":\"v1\"}")

assertEquals("{\"list\":[]}", cache.load("/vaults")?.data)
assertEquals("{\"id\":\"v1\"}", cache.load("/vaults/v1")?.data)
}

@Test
fun `evicts the least recently used entry once the byte cap is exceeded`() {
val payload = "x".repeat(100)
// Each envelope on disk is ~137 bytes (13-digit timestamp + JSON overhead + payload).
// 320 comfortably fits two entries (~274 bytes) but not three (~411 bytes), so eviction
// is only forced by the third save below.
val cache = newCache().apply { maxCacheBytes = 320 }

cache.save("/a", payload)
cache.save("/b", payload)
cache.load("/a") // touch "a" so "b" becomes the least recently used
cache.save("/c", payload) // should evict "b", not "a"

assertNotNull("most-recently-used entry should survive eviction", cache.load("/a"))
assertNull("least-recently-used entry should be evicted", cache.load("/b"))
assertNotNull("newest entry should survive eviction", cache.load("/c"))
}

@Test
fun `never evicts while under the byte cap`() {
val cache = newCache().apply { maxCacheBytes = OfflineCache.DEFAULT_MAX_CACHE_BYTES }

cache.save("/a", "small")
cache.save("/b", "small")
cache.save("/c", "small")

assertNotNull(cache.load("/a"))
assertNotNull(cache.load("/b"))
assertNotNull(cache.load("/c"))
}

@Test
fun `clear removes every cached entry`() {
val cache = newCache()
cache.save("/a", "{}")
cache.save("/b", "{}")

cache.clear()

assertNull(cache.load("/a"))
assertNull(cache.load("/b"))
}
}