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
26 changes: 24 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@

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

# Android snapshot test images
android/app/src/test/snapshots/

# Android build outputs
android/.gradle/
android/build/
android/app/build/

# iOS generated Xcode project (regenerated by xcodegen)
ios/EthosProtocol/Xcode/

# iOS build artefacts
ios/EthosProtocol/.build/
*.xcuserdata/
*.xcworkspace/xcuserdata/

# macOS
.DS_Store

# Editor
.idea/
*.swp
8 changes: 4 additions & 4 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ Last audited: 2026-07-27
| Offline queue badge / notification | ❌ | ✅ | |
| **Widget** | | | |
| Home-screen vault TTL widget | ✅ | ✅ | iOS: WidgetKit TTLWidget; Android: VaultStatusWidget (Glance) |
| TTL-aware refresh policy | ✅ | | Android widget polls at a fixed interval; no urgency scaling (#TBD) |
| Widget urgency selection (which vault to surface) | | | Both platforms always show the first vault (#TBD) |
| TTL-aware refresh policy | ✅ | | Android VaultWidgetUpdateWorker uses determineUpdateInterval for urgency-scaled refresh (#247) |
| Widget urgency selection (which vault to surface) | | | Both platforms show the most-urgent vault by default; users can pin a specific vault via VaultSelectionIntent (iOS) or VaultWidgetConfigActivity (Android) (#245 #246) |
| **WebSocket / real-time** | | | |
| Live vault updates via WebSocket | ✅ | ✅ | iOS: VaultEventSocket.swift; Android: VaultEventSocket.kt, both wired into their vault store/ViewModel with reconnect backoff |
| **Background refresh** | | | |
Expand Down Expand Up @@ -97,8 +97,8 @@ Each gap has a tracking issue; fix it on the lagging platform and update this ta
| Check-in reminder lead-time scaling | Android | TBD |
| Actionable push notification action (CHECK_IN) | Android | TBD |
| Offline check-in queue | iOS | TBD |
| TTL-aware widget refresh policy | Android | TBD |
| Widget urgency / vault selection | Both | TBD |
| TTL-aware widget refresh policy | Android | Resolved in #247 |
| Widget urgency / vault selection | Both | Resolved in #245 / #246 |
| iCloud / cross-device sync | Android | TBD |

---
Expand Down
1 change: 1 addition & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
local.properties
**/build/
/dependency-check-data/
src/test/snapshots/
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@
android:resource="@xml/vault_widget_info" />
</receiver>

<!-- Widget configuration activity: shown when the user adds a new vault widget (#245) -->
<activity
android:name=".widget.VaultWidgetConfigActivity"
android:exported="true"
android:label="@string/widget_configure_title"
android:theme="@style/Theme.EthosProtocol">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<!-- Passkey / Digital Asset Links -->
<meta-data
android:name="asset_statements"
Expand Down
39 changes: 26 additions & 13 deletions android/app/src/main/java/com/ethosprotocol/api/Infrastructure.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import java.time.Instant
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.withLock

@Singleton
class NetworkMonitor @Inject constructor(@ApplicationContext private val context: Context) {
Expand Down Expand Up @@ -43,6 +44,11 @@ class OfflineCache @Inject constructor(@ApplicationContext private val context:
// fighting Hilt's @Inject constructor resolution.
internal var maxCacheBytes: Long = DEFAULT_MAX_CACHE_BYTES

// ReentrantReadWriteLock: multiple concurrent reads are fine (readLock), but writes
// (save / evictIfNeeded) and clear() require exclusive access (writeLock) to prevent
// torn writes where a concurrent reader sees a partially-written cache file (#244).
private val lock = java.util.concurrent.locks.ReentrantReadWriteLock()

// 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
Expand All @@ -58,34 +64,41 @@ class OfflineCache @Inject constructor(@ApplicationContext private val context:
}

fun save(key: String, json: String) {
val fileName = key.sha256()
val envelope = CacheEnvelope(timestamp = System.currentTimeMillis(), data = json)
File(dir, fileName).writeText(Json.encodeToString(CacheEnvelope.serializer(), envelope))
touch(fileName)
evictIfNeeded()
lock.writeLock().withLock {
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
lock.readLock().withLock {
val fileName = key.sha256()
val envelope = runCatching {
Json.decodeFromString(CacheEnvelope.serializer(), File(dir, fileName).readText())
}.getOrNull()
if (envelope != null) touch(fileName)
return envelope
}
}

// 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()
lock.writeLock().withLock {
dir.listFiles()?.forEach { it.delete() }
accessOrder.clear()
}
}

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

private fun evictIfNeeded() {
// Called only from within a writeLock block — no additional locking needed here.
var totalSize = dir.listFiles()?.sumOf { it.length() } ?: 0L
if (totalSize <= maxCacheBytes) return
val leastRecentlyUsed = synchronized(accessOrder) { accessOrder.keys.toList() }
Expand Down
154 changes: 131 additions & 23 deletions android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.RemoteViews
import androidx.hilt.work.HiltWorker
import androidx.work.*
Expand All @@ -27,31 +28,107 @@ class VaultStatusWidget : AppWidgetProvider() {
widgetIds.forEach { updateWidget(context, manager, it) }
}

/** Re-render when the user resizes the widget so the layout adapts to the new size (#247). */
override fun onAppWidgetOptionsChanged(
context: Context,
manager: AppWidgetManager,
widgetId: Int,
newOptions: Bundle
) {
super.onAppWidgetOptionsChanged(context, manager, widgetId, newOptions)
updateWidget(context, manager, widgetId)
}

companion object {
private const val PREFS = "vault_widget_prefs"
// Per-widget shared-prefs key pattern (#246). Each widget instance gets its own
// preferences file so different widgets can show different vaults simultaneously.
private fun prefsName(widgetId: Int) = "vault_widget_prefs_$widgetId"

// Shared prefs key used to store the list of all known vault IDs, for use by
// VaultWidgetConfigActivity's vault picker (#245).
const val PREFS_SHARED = "vault_widget_shared"
const val KEY_VAULT_ID_LIST = "vault_id_list"

private const val KEY_VAULT_ID = "vault_id"
private const val KEY_VAULT_NAME = "vault_name"
private const val KEY_TTL = "ttl_remaining"
private const val KEY_LAST_CHECK_IN = "last_check_in"
const val KEY_BALANCE = "balance"
const val KEY_BENEFICIARY = "beneficiary"

// Selected-vault key stored in per-widget prefs; written by VaultWidgetConfigActivity.
private const val KEY_SELECTED_VAULT_ID = "selected_vault_id"

fun saveVaultData(context: Context, vaultId: String, vaultName: String, ttlRemaining: String, lastCheckIn: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
/**
* Saves vault display data to per-widget SharedPreferences (#246).
* Each widget ID maps to its own prefs file so data is isolated per instance.
*/
fun saveVaultData(
context: Context,
widgetId: Int,
vaultId: String,
vaultName: String,
ttlRemaining: String,
lastCheckIn: String,
balance: String,
beneficiary: String
) {
context.getSharedPreferences(prefsName(widgetId), Context.MODE_PRIVATE).edit()
.putString(KEY_VAULT_ID, vaultId)
.putString(KEY_VAULT_NAME, vaultName)
.putString(KEY_TTL, ttlRemaining)
.putString(KEY_LAST_CHECK_IN, lastCheckIn)
.putString(KEY_BALANCE, balance)
.putString(KEY_BENEFICIARY, beneficiary)
.apply()
}

/**
* Returns the vault ID pinned by the user for this widget instance via
* VaultWidgetConfigActivity, or null if the user has not made a selection (#245 / #246).
*/
fun getSelectedVaultId(context: Context, widgetId: Int): String? =
context.getSharedPreferences(prefsName(widgetId), Context.MODE_PRIVATE)
.getString(KEY_SELECTED_VAULT_ID, null)
.takeIf { !it.isNullOrEmpty() }

/**
* Persists the user's vault selection for a specific widget instance (#245 / #246).
* Called by VaultWidgetConfigActivity when the user picks a vault.
*/
fun saveSelectedVaultId(context: Context, widgetId: Int, vaultId: String) {
context.getSharedPreferences(prefsName(widgetId), Context.MODE_PRIVATE).edit()
.putString(KEY_SELECTED_VAULT_ID, vaultId)
.apply()
}

/**
* Chooses the correct layout resource based on the widget's current width (#247).
* Reads OPTION_APPWIDGET_MIN_WIDTH from the options bundle:
* width < 180dp → small (TTL only)
* 180 ≤ width < 250dp → medium (TTL + balance)
* width ≥ 250dp → large (TTL + balance + beneficiary)
*/
fun selectLayout(options: Bundle): Int {
val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
return when {
minWidth >= 250 -> R.layout.vault_widget_large
minWidth >= 180 -> R.layout.vault_widget_medium
else -> R.layout.vault_widget_small
}
}

/** Builds the deep link used to open a widget tap directly onto the vault it displayed. */
internal fun deepLinkUri(vaultId: String): String = "ethosprotocol://vault/$vaultId/view-details"

fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val prefs = context.getSharedPreferences(prefsName(widgetId), Context.MODE_PRIVATE)
val vaultId = prefs.getString(KEY_VAULT_ID, null)
val vaultName = prefs.getString(KEY_VAULT_NAME, "—") ?: "—"
val ttl = prefs.getString(KEY_TTL, "Unknown") ?: "Unknown"
val lastCheckIn = prefs.getString(KEY_LAST_CHECK_IN, "Never") ?: "Never"
val balance = prefs.getString(KEY_BALANCE, "—") ?: "—"
val beneficiary = prefs.getString(KEY_BENEFICIARY, "—") ?: "—"

val openIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
Expand All @@ -60,14 +137,22 @@ class VaultStatusWidget : AppWidgetProvider() {
}
}
val pendingIntent = PendingIntent.getActivity(
context, 0, openIntent,
context, widgetId, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val views = RemoteViews(context.packageName, R.layout.vault_widget).apply {
// Pick layout based on current widget size options (#247).
val options = manager.getAppWidgetOptions(widgetId)
val layoutId = selectLayout(options)

val views = RemoteViews(context.packageName, layoutId).apply {
setTextViewText(R.id.widget_vault_name, vaultName)
setTextViewText(R.id.widget_ttl, "TTL: $ttl")
setTextViewText(R.id.widget_last_check_in, "Last check-in: $lastCheckIn")
// Medium and large layouts include balance / beneficiary views.
// setTextViewText on a view that doesn't exist in the current layout is a no-op
// for RemoteViews, so these calls are safe across all layout sizes.
setTextViewText(R.id.widget_balance, balance)
setTextViewText(R.id.widget_beneficiary, beneficiary)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
}
manager.updateAppWidget(widgetId, views)
Expand Down Expand Up @@ -106,24 +191,47 @@ class VaultWidgetUpdateWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
val result = apiClient.listVaults()
if (result is ApiResult.Success) {
// Pick the active vault with the lowest ttlRemaining — the same
// "most urgent vault" selection that iOS TTLTimelineProvider uses
// (min(by:) on ttlRemaining). Using firstOrNull() would show a
// different vault than iOS for accounts with multiple vaults.
val vault = result.data
.filter { it.status == VaultStatus.active }
.minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE }
val vaults = result.data.filter { it.status == VaultStatus.active }

// Pick the active vault with the lowest ttlRemaining as the urgency fallback.
// Individual widget instances may override this with a pinned vault ID (#245/#246).
val urgentVault = vaults.minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE }
?: return Result.success()
val ttl = formatTtl(vault.ttlRemaining)
VaultStatusWidget.saveVaultData(
applicationContext,
vaultId = vault.id,
vaultName = vault.id.take(12) + "…",
ttlRemaining = ttl,
lastCheckIn = VaultStatusWidget.formatLastCheckIn(vault.lastCheckIn)

// Save the full vault ID list so VaultWidgetConfigActivity can populate
// the picker (#245).
val vaultIdList = vaults.joinToString(",") { it.id }
applicationContext.getSharedPreferences(
VaultStatusWidget.PREFS_SHARED, Context.MODE_PRIVATE
).edit().putString(VaultStatusWidget.KEY_VAULT_ID_LIST, vaultIdList).apply()

// Update each widget instance independently (#246).
// If the user has pinned a specific vault, use that; otherwise fall back to urgentVault.
val manager = AppWidgetManager.getInstance(applicationContext)
val ids = manager.getAppWidgetIds(
ComponentName(applicationContext, VaultStatusWidget::class.java)
)
VaultStatusWidget.refreshAll(applicationContext)
schedule(applicationContext, determineUpdateInterval(vault.ttlRemaining))
ids.forEach { widgetId ->
val pinnedId = VaultStatusWidget.getSelectedVaultId(applicationContext, widgetId)
val vault = if (pinnedId != null) {
vaults.find { it.id == pinnedId } ?: urgentVault
} else {
urgentVault
}
VaultStatusWidget.saveVaultData(
applicationContext,
widgetId = widgetId,
vaultId = vault.id,
vaultName = vault.id.take(12) + "…",
ttlRemaining = formatTtl(vault.ttlRemaining),
lastCheckIn = VaultStatusWidget.formatLastCheckIn(vault.lastCheckIn),
balance = vault.formattedBalance,
beneficiary = vault.beneficiary.take(12) + "…"
)
VaultStatusWidget.updateWidget(applicationContext, manager, widgetId)
}

schedule(applicationContext, determineUpdateInterval(urgentVault.ttlRemaining))
}
return Result.success()
}
Expand Down
Loading