Skip to content
Open
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: 3 additions & 3 deletions frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,14 @@ struct BitBoxAppApp: App {
setupGoAPI(goAPI: goAPI)
MobileserverSetOnline(NetworkMonitor.shared.isOnline())
Task.detached(priority: .utility) {
widgetSync.sync()
widgetSync.sync(forceReload: true)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
MobileserverManualReconnect()
MobileserverTriggerAuth()
Task.detached(priority: .utility) {
widgetSync.sync()
widgetSync.sync(forceReload: true)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
Expand All @@ -219,7 +219,7 @@ struct BitBoxAppApp: App {
}
}
Task.detached(priority: .userInitiated) {
widgetSync.sync()
widgetSync.sync(forceReload: true)
await MainActor.run {
if bgTask != .invalid {
UIApplication.shared.endBackgroundTask(bgTask)
Expand Down
12 changes: 8 additions & 4 deletions frontends/ios/BitBoxApp/BitBoxApp/WidgetAppGroupSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ struct WidgetAppGroupSync {

private static let serialQueue = DispatchQueue(label: "swiss.bitbox.WidgetAppGroupSync")

func sync() {
func sync(forceReload: Bool = false) {
#if !TARGET_TESTNET
Self.serialQueue.sync {
performSync()
performSync(forceReload: forceReload)
}
#endif
}

#if !TARGET_TESTNET
private func performSync() {
private func performSync(forceReload: Bool) {
let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
guard let defaults = UserDefaults(suiteName: WidgetShared.appGroupID) else {
return
Expand Down Expand Up @@ -78,7 +78,11 @@ struct WidgetAppGroupSync {
}
}

if changed {
if forceReload {
defaults.set(true, forKey: WidgetShared.Keys.forceFreshPriceReload)
}

if changed || forceReload {
WidgetCenter.shared.reloadAllTimelines()
}
}
Expand Down
1 change: 1 addition & 0 deletions frontends/ios/BitBoxApp/BitBoxApp/WidgetShared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum WidgetShared {
static let rawMainFiat = "rawMainFiat"
static let sharedCoins = "activeCoins"
static let selectedCoinIndex = "selectedCoinIndex"
static let forceFreshPriceReload = "forceFreshPriceReload"
}

enum Cache {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ enum WidgetAppGroupStore {
defaults?.set(index, forKey: WidgetShared.Keys.selectedCoinIndex)
}

static func shouldForceFreshPriceReload() -> Bool {
defaults?.bool(forKey: WidgetShared.Keys.forceFreshPriceReload) == true
}

static func clearForceFreshPriceReload() {
defaults?.set(false, forKey: WidgetShared.Keys.forceFreshPriceReload)
}

static func selectedCoinCode() -> String {
let coins = activeCoins()
guard !coins.isEmpty else {
Expand Down
40 changes: 37 additions & 3 deletions frontends/ios/BitBoxApp/BitBoxAppWidget/WidgetDataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ struct WidgetDataService {
return nil
}

async let currentPriceFromSimplePrice = fetchCurrentPrice(coinCode: coinCode, currency: currency)

do {
let (data, response) = try await session.data(from: url)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
Expand All @@ -85,18 +87,21 @@ struct WidgetDataService {
return nil
}

guard let currentPrice = prices.last,
guard let rangeCurrentPrice = prices.last,
let firstPrice = prices.first,
currentPrice.isFinite,
rangeCurrentPrice.isFinite,
firstPrice.isFinite,
firstPrice != 0 else {
return nil
}
let simplePrice = await currentPriceFromSimplePrice
let currentPrice = simplePrice ?? rangeCurrentPrice
let chartPrices = simplePrice.map { prices + [$0] } ?? prices
let change = (currentPrice - firstPrice) / firstPrice * 100
let result = PriceData(
price: currentPrice,
change24h: change,
chartPrices: prices,
chartPrices: chartPrices,
coinCode: WidgetShared.normalizeCoinCode(coinCode),
currency: currency.uppercased()
)
Expand All @@ -107,13 +112,42 @@ struct WidgetDataService {
}
}

private func fetchCurrentPrice(coinCode: String, currency: String) async -> Double? {
guard let url = simplePriceURL(for: coinCode, currency: currency) else {
return nil
}

do {
let (data, response) = try await session.data(from: url)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
guard statusCode == 200,
let decoded = try? JSONDecoder().decode([String: [String: Double]].self, from: data) else {
return nil
}
let geckoID = WidgetCoinMetadata.geckoID(for: coinCode)
let geckoCurrency = currency.lowercased()
guard let currentPrice = decoded[geckoID]?[geckoCurrency],
currentPrice.isFinite else {
return nil
}
return currentPrice
} catch {
return nil
}
}

private func chartURL(for coinCode: String, currency: String) -> URL? {
let now = Int(Date().timeIntervalSince1970)
let oneDayAgo = now - Self.chartRangeSeconds
let geckoID = WidgetCoinMetadata.geckoID(for: coinCode)
return URL(string: "https://exchangerates.shiftcrypto.io/api/v3/coins/\(geckoID)/market_chart/range?vs_currency=\(currency.lowercased())&from=\(oneDayAgo)&to=\(now)")
}

private func simplePriceURL(for coinCode: String, currency: String) -> URL? {
let geckoID = WidgetCoinMetadata.geckoID(for: coinCode)
return URL(string: "https://exchangerates.shiftcrypto.io/api/v3/simple/price?ids=\(geckoID)&vs_currencies=\(currency.lowercased())")
}

private static func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
let coinCode = WidgetAppGroupStore.selectedCoinCode()
let currency = WidgetAppGroupStore.userCurrency()
let forceFreshPriceReload = WidgetAppGroupStore.shouldForceFreshPriceReload()

let cached = dataService.cachedFallback(for: coinCode, currency: currency)
let cached = forceFreshPriceReload ? nil : dataService.cachedFallback(for: coinCode, currency: currency)
let exactCacheHit = cached.flatMap { $0.currency.uppercased() == currency.uppercased() ? $0 : nil }

if let exactCacheHit {
Expand All @@ -56,6 +57,9 @@ struct Provider: TimelineProvider {
} else {
Task {
let fetched = await dataService.fetchChartData(coinCode: coinCode, currency: currency)
if fetched != nil {
WidgetAppGroupStore.clearForceFreshPriceReload()
}
let data = fetched ?? dataService.cachedFallback(for: coinCode, currency: currency)
let entry = resolvedEntry(for: coinCode, currency: currency, data: data)
let nextUpdate = Date().addingTimeInterval(fetched == nil ? Self.retryInterval : Self.refreshInterval)
Expand Down
28 changes: 21 additions & 7 deletions frontends/ios/WIDGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ When the app launches (or enters foreground / background), `WidgetAppGroupSync`
2. Reads `accounts.json` -> collects all active (non-inactive, non-hidden) coin codes.
3. Normalizes coin codes (`tbtc` -> `btc`, `sepeth` -> `eth`, `tltc` -> `ltc`).
4. Writes the currency and coin list to shared `UserDefaults`.
5. If anything changed, tells iOS to reload the widget timeline.
5. Tells iOS to reload the widget timeline on app launch / foreground / background.
6. Marks that lifecycle reload as a fresh-price reload so the widget skips its cache once.

**Fallback:** If the user's currency isn't supported by the API (e.g. `BTC` or `sat`), it falls back to `USD`.

Expand All @@ -46,7 +47,8 @@ The widget shows **one coin at a time**. The user switches coins with left/right

## 3. How prices are fetched

The widget calls the **Shift Crypto CoinGecko Mirror API**:
The widget calls the **Shift Crypto CoinGecko Mirror API**. It uses the chart endpoint for historical
data:

```
https://exchangerates.shiftcrypto.io/api/v3/coins/{geckoID}/market_chart/range
Expand All @@ -55,15 +57,24 @@ https://exchangerates.shiftcrypto.io/api/v3/coins/{geckoID}/market_chart/range
&to={now}
```

It also calls the simple price endpoint for a fresher current price:

```
https://exchangerates.shiftcrypto.io/api/v3/simple/price
?ids={geckoID}
&vs_currencies={currency}
```

Where `geckoID` maps `btc` -> `bitcoin`, `ltc` -> `litecoin`, `eth` -> `ethereum`.

This returns 24 hours of price data points. From that response:
The chart response returns 24 hours of price data points. The simple price response is appended to
that series when available. From those responses:

- **Current price** = last data point.
- **24-hour change** = percentage difference between first and last data points.
- **Chart data** = all returned price points (used for the sparkline).
- **Current price** = simple price when available, otherwise the chart response's last data point.
- **24-hour change** = percentage difference between the chart response's first data point and the current price.
- **Chart data** = all returned chart price points plus the simple price when available (used for the sparkline).

The request has a **10-second timeout**.
Each request has a **10-second timeout**.

---

Expand All @@ -88,6 +99,8 @@ When the widget refreshes, it follows this order:

Switching to a coin with a warm cache feels instant. The first switch to a coin whose cache is cold (or expired) performs one inline fetch before rendering.

App lifecycle reloads (launch / foreground / background) bypass the cache and attempt a fresh network fetch. If that fetch fails, the widget keeps bypassing the cache on retry until a fresh fetch succeeds.

### Fallback chain

If the network fetch fails:
Expand All @@ -105,6 +118,7 @@ If the network fetch fails:
- With the 10-minute cache TTL, each iOS-granted timeline reload should either use recent cached data or fetch fresh data.
- If a network fetch fails, the widget asks iOS to retry in **1 minute**.
- If the user changes their currency or accounts in the app, a refresh is triggered immediately.
- App launch / foreground / background also triggers a widget timeline reload that bypasses the cache once.

---

Expand Down