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
52 changes: 51 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,54 @@

# Byte-compiled CI helper scripts
__pycache__/
*.pyc

# ── iOS ─────────────────────────────────────────────────────────────────────
# XcodeGen-generated project (generated on demand, never committed)
ios/EthosProtocol/Xcode/

# Xcode derived data & build artefacts
DerivedData/
build/
*.xcarchive
*.ipa
*.dSYM.zip
*.dSYM

# Xcode user-specific state
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcworkspace/contents.xcworkspacedata
*.xcuserstate

# Swift Package Manager
.build/
*.resolved

# Test snapshots (SnapshotTesting / Paparazzi)
**/__Snapshots__/
**/ReferenceImages/
**/FailureDiffs/
**/snapshots/

# ── Android ─────────────────────────────────────────────────────────────────
android/.gradle/
android/app/build/
android/build/
*.jks
*.keystore
android/local.properties
android/app/google-services.json
google-services.json

# Gradle wrapper jar (downloaded at build time)
gradle/wrapper/gradle-wrapper.jar

# ── General ──────────────────────────────────────────────────────────────────
.DS_Store
**/.DS_Store
*.log
*.orig
.env
.env.*
.idea/
*.iml
3 changes: 2 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/models/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ data class BeneficiaryUpdateRequest(val beneficiary: String)
@Serializable
data class PushRegistration(
val token: String,
val platform: String = "android"
val platform: String = "android",
val locale: String = java.util.Locale.getDefault().toLanguageTag()
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.ethosprotocol.services.PendingActionType
import com.ethosprotocol.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
Expand All @@ -20,6 +21,8 @@ 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
const val EXPIRED_CHANNEL_ID = "vault_expired"
const val EXPIRED_CHANNEL_NAME = "Vault Expiry Alerts"

// Reserved range for per-vault notification IDs, kept clear of QUEUED_NOTIFICATION_ID
// and NO_VAULT_NOTIFICATION_ID below.
Expand All @@ -42,6 +45,7 @@ class NotificationHelper @Inject constructor(@ApplicationContext private val con
init {
createChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH)
createChannel(QUEUED_CHANNEL_ID, QUEUED_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
createChannel(EXPIRED_CHANNEL_ID, EXPIRED_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH)
}

@Synchronized
Expand Down Expand Up @@ -106,6 +110,32 @@ class NotificationHelper @Inject constructor(@ApplicationContext private val con
context.getSystemService(NotificationManager::class.java).cancel(QUEUED_NOTIFICATION_ID)
}

fun showVaultExpiredNotification(vaultId: String, actionType: PendingActionType) {
val actionLabel = if (actionType == PendingActionType.CHECK_IN) "check-in" else "request"
val body = "A queued $actionLabel was discarded because this vault already expired " +
"while you were offline. The vault may have released funds to the beneficiary."
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (vaultId.isNotEmpty())
data = android.net.Uri.parse("ethosprotocol://vault/$vaultId/view-details")
}
val pi = PendingIntent.getActivity(
context, vaultId.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, EXPIRED_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_lock)
.setContentTitle("Check-in Failed \u2014 Vault Expired")
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
context.getSystemService(NotificationManager::class.java)
.notify(notificationIdFor(vaultId.ifEmpty { null }), notification)
}

private fun createChannel(id: String, name: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ data class PendingAction(
// each NULL in a unique index as distinct, so action types with no natural key
// (e.g. create-vault) can simply leave this null and queue freely.
val dedupeKey: String? = null
)
) {
companion object {
const val MAX_QUEUE_SIZE = 50
}
}

@Dao
interface PendingActionDao {
@Query("SELECT * FROM pending_actions ORDER BY queuedAt ASC")
suspend fun getAll(): List<PendingAction>

@Query("SELECT * FROM pending_actions ORDER BY queuedAt ASC LIMIT :n")
suspend fun getOldest(n: Int): List<PendingAction>

@Query("SELECT COUNT(*) FROM pending_actions")
fun observeCount(): Flow<Int>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ class PendingActionSyncWorker @AssistedInject constructor(
// (server error, timeout, expired auth) would lose that intent. Only drop
// the item when the server has definitively rejected it as invalid (e.g.
// the vault no longer exists) — everything else is retried.
if (result.code in NON_RETRYABLE_ERROR_CODES) {
if (result.code == VAULT_EXPIRED_CODE) {
dao.delete(item)
permanentlyFailed++
notificationHelper.showVaultExpiredNotification(item.vaultId ?: "", item.type)
Log.e(TAG, "action dropped (vault expired) type=${item.type} vaultId=${item.vaultId}")
} else if (result.code in NON_RETRYABLE_ERROR_CODES) {
dao.delete(item)
permanentlyFailed++
Log.e(TAG, "action dropped (non-retryable) type=${item.type} vaultId=${item.vaultId} code=${result.code} msg=${result.message}")
Expand Down Expand Up @@ -128,7 +133,8 @@ class PendingActionSyncWorker @AssistedInject constructor(
// Error codes where the server has told us unambiguously that this action can
// never succeed (bad request / vault no longer exists), so retrying is pointless.
// Everything else (5xx, 401, 0/exception) is treated as transient and retried.
private val NON_RETRYABLE_ERROR_CODES = setOf(400, 404, 410)
private val NON_RETRYABLE_ERROR_CODES = setOf(400, 404)
private const val VAULT_EXPIRED_CODE = 410

fun schedule(context: Context) {
val request = OneTimeWorkRequestBuilder<PendingActionSyncWorker>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class TTLFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var tokenProvider: TokenProvider

override fun onNewToken(token: String) {
// locale is included in PushRegistration so the server can localise push payloads per-user
tokenProvider.pushToken = token
CoroutineScope(Dispatchers.IO).launch {
apiClient.registerPushToken(token)
Expand All @@ -28,6 +29,8 @@ class TTLFirebaseMessagingService : FirebaseMessagingService() {
val vaultId = message.data["vault_id"]
val type = message.data["type"] ?: "reminder"
val title = message.notification?.title ?: "Ethos-Protocol"
// Fallback body strings are English; server-side payloads are localised via the
// registered locale. Add res/values-*/strings.xml entries to localise these fallbacks.
val body = message.notification?.body ?: when (type) {
"expiry_warning" -> "Your vault is expiring soon. Check in now."
"released" -> "Your vault has been released to the beneficiary."
Expand Down
20 changes: 17 additions & 3 deletions android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,9 @@ data class VaultUiState(
val hasMore: Boolean = false,
val error: String? = null,
val isOffline: Boolean = false,
val beneficiaryUpdated: Boolean = false
val beneficiaryUpdated: Boolean = false,
val queueNearCapacity: Boolean = false,
val queueAtCapacity: Boolean = false
)

private const val PAGE_SIZE = 20
Expand Down Expand Up @@ -544,9 +546,21 @@ class VaultViewModel @Inject constructor(
private suspend fun queueAction(action: PendingAction) {
pendingActionDao.insert(action)
val queued = pendingActionDao.getAll()
notificationHelper.showQueuedActions(queued.size)
// Enforce queue size cap — remove oldest items beyond the limit
if (queued.size > PendingAction.MAX_QUEUE_SIZE) {
val overflow = queued.size - PendingAction.MAX_QUEUE_SIZE
queued.take(overflow).forEach { pendingActionDao.delete(it) }
}
val currentCount = pendingActionDao.getAll().size
val atCapacity = currentCount >= PendingAction.MAX_QUEUE_SIZE
val nearCapacity = currentCount >= PendingAction.MAX_QUEUE_SIZE - 5
notificationHelper.showQueuedActions(currentCount)
PendingActionSyncWorker.schedule(context)
_state.update { it.copy(error = "Offline — request queued and will retry automatically") }
val errorMessage = if (atCapacity)
"Offline — queue is full (oldest request replaced). Will retry automatically."
else
"Offline — request queued and will retry automatically"
_state.update { it.copy(error = errorMessage, queueAtCapacity = atCapacity, queueNearCapacity = nearCapacity) }
}
}

Expand Down
32 changes: 28 additions & 4 deletions android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ fun VaultListScreen(
if (state.isOffline) item {
OfflineBanner()
}
if (state.queueAtCapacity) item {
QueueCapacityBanner(atCapacity = true)
} else if (state.queueNearCapacity) item {
QueueCapacityBanner(atCapacity = false)
}
val errorMsg = biometricError ?: state.error
errorMsg?.let { err ->
item {
Expand Down Expand Up @@ -348,8 +353,7 @@ private fun CheckInConfirmationDialog(vault: Vault, onConfirm: () -> Unit, onDis
}

@Composable
private fun OfflineBanner(cachedAt: Long? = null) {
val message = if (cachedAt != null) {
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"
Expand All @@ -370,8 +374,28 @@ private fun OfflineBanner(cachedAt: Long? = null) {
}
}

private fun formatCacheAge(cachedAt: Long): String {
val elapsedSeconds = ((System.currentTimeMillis() - cachedAt) / 1000).coerceAtLeast(0)
@Composable
private fun QueueCapacityBanner(atCapacity: Boolean) {
val color = if (atCapacity) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.tertiaryContainer
val textColor = if (atCapacity) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onTertiaryContainer
val message = if (atCapacity) "Offline queue full — oldest request replaced"
else "Offline queue nearly full"
Surface(color = color) {
Row(
Modifier.fillMaxWidth().padding(12.dp).semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, contentDescription = if (atCapacity) "Queue full" else "Queue nearly full",
tint = textColor)
Spacer(Modifier.width(8.dp))
Text(message, color = textColor, 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.ethosprotocol

import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import com.ethosprotocol.services.VaultDeepLinkParser
import com.ethosprotocol.ui.DeepLinkViewModel
import org.junit.Assert.*
import org.junit.Test

/**
* Covers the cold-start case for #236: app fully killed, notification tapped,
* Android delivers the intent/URI to MainActivity. The key behaviours are:
* 1. VaultDeepLinkParser correctly parses the check-in URI carried in the notification.
* 2. DeepLinkViewModel (backed by SavedStateHandle) preserves the pending deep link
* across process death/recreation, mirroring what happens when the OS restores the
* Activity from the notification tap.
* 3. No crash if the vault referenced by the notification no longer exists.
*/
class ColdStartDeepLinkTest {

// ── VaultDeepLinkParser ────────────────────────────────────────────────────

@Test
fun `coldStart validCheckInUri parses to VaultDeepLink`() {
val uri = Uri.parse("ethosprotocol://vault/abc123/check-in")
val result = VaultDeepLinkParser.parse(uri)
assertNotNull("Expected a VaultDeepLink for a valid cold-start URI", result)
assertEquals("abc123", result!!.vaultId)
}

@Test
fun `coldStart arbitrary vaultId does not crash parser`() {
// A vault that no longer exists still has a valid-format ID; the parser
// should return a DeepLink — it is not the parser's job to validate
// whether the vault actually exists server-side.
val vaultId = "vault-that-no-longer-exists-12345"
val uri = Uri.parse("ethosprotocol://vault/$vaultId/check-in")
val result = VaultDeepLinkParser.parse(uri)
assertNotNull(result)
assertEquals(vaultId, result!!.vaultId)
}

@Test
fun `coldStart emptyVaultId returns null`() {
// ethosprotocol://vault//check-in — empty vault ID segment
val uri = Uri.parse("ethosprotocol://vault//check-in")
val result = VaultDeepLinkParser.parse(uri)
assertNull("Empty vault ID should return null", result)
}

@Test
fun `coldStart wrongScheme returns null`() {
val uri = Uri.parse("https://ethos-protocol.app/vault/abc123/check-in")
// VaultDeepLinkParser only handles the custom scheme deep-links
// (https universal links go through UniversalLinkRouter on iOS; on Android
// they are handled via intent-filter, but the parser itself only handles
// the ethosprotocol:// scheme used by notification intents).
val result = VaultDeepLinkParser.parse(uri)
// The parser may or may not handle https — just assert no exception is thrown.
// If it returns null, that is also acceptable.
// This test simply verifies no crash.
}

// ── DeepLinkViewModel (process-death survival) ─────────────────────────────

@Test
fun `coldStart pendingVaultDeepLink survivesProcessDeath via SavedStateHandle`() {
// Simulate: Activity is killed while a deep link is pending, then recreated.
// SavedStateHandle persists the pending link across process death.
val savedState = SavedStateHandle()
val vm = DeepLinkViewModel(savedState)

val uri = Uri.parse("ethosprotocol://vault/saved-vault-id/check-in")
val deepLink = VaultDeepLinkParser.parse(uri)!!
vm.setPendingVaultDeepLink(deepLink)

// Simulate process death: create a new ViewModel with the same SavedStateHandle
// (the OS restores the handle after process death).
val restoredVm = DeepLinkViewModel(savedState)
val pending = restoredVm.pendingVaultDeepLink.value
assertNotNull("Pending deep link should survive process death", pending)
assertEquals("saved-vault-id", pending!!.vaultId)
}

@Test
fun `coldStart consumeVaultDeepLink clears state`() {
val savedState = SavedStateHandle()
val vm = DeepLinkViewModel(savedState)

val uri = Uri.parse("ethosprotocol://vault/abc123/check-in")
val deepLink = VaultDeepLinkParser.parse(uri)!!
vm.setPendingVaultDeepLink(deepLink)
vm.consumeVaultDeepLink()

assertNull(vm.pendingVaultDeepLink.value)
}
}
1 change: 1 addition & 0 deletions ios/EthosProtocol/Sources/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ struct AccountRecoveryProof: Codable {
struct PushRegistration: Codable {
let token: String
let platform: String // "ios" | "android"
let locale: String // BCP 47 language tag, e.g. "en-US"
}

// MARK: - 2FA Models
Expand Down
Loading