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
19 changes: 19 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,26 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links from the home-screen widget quick actions -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="flash" />
</intent-filter>
</activity>

<!-- Home-screen widget: live BTC price + Scan/Receive quick actions -->
<receiver
android:name=".widget.FlashWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/flash_widget_info" />
</receiver>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />

<meta-data
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/lnflash/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.lnflash.widget.WidgetBridgePackage

class MainApplication : Application(), ReactApplication {

Expand All @@ -20,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(WidgetBridgePackage())
}

override fun getJSMainModuleName(): String = "index"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.lnflash.widget

import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.RemoteViews
import com.lnflash.R

/**
* Home-screen widget showing the live BTC price plus Scan / Receive quick
* actions. Picks a small / medium / large layout based on the widget's current
* size and refreshes the price in the background on each update.
*/
class FlashWidgetProvider : AppWidgetProvider() {

override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// Refresh price off the main thread, then re-render every instance.
Thread {
val snapshot = PriceFetcher.fetch(WidgetStore.read(context))
WidgetStore.write(context, snapshot)
for (id in appWidgetIds) {
renderWidget(context, appWidgetManager, id, snapshot)
}
}.start()
}

override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle,
) {
renderWidget(context, appWidgetManager, appWidgetId, WidgetStore.read(context))
}

private fun renderWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
snapshot: WidgetStore.Snapshot,
) {
val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0)
val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0)

val layoutId = when {
minHeight >= 180 -> R.layout.flash_widget_large
minWidth >= 180 -> R.layout.flash_widget_medium
else -> R.layout.flash_widget_small
}

val views = RemoteViews(context.packageName, layoutId).apply {
setTextViewText(R.id.widget_price, snapshot.formattedPrice())
setTextViewText(R.id.widget_currency, snapshot.currencyCode)

// Whole-widget tap opens the app; action buttons deep-link to screens.
setOnClickPendingIntent(R.id.widget_root, deepLinkIntent(context, "flash://home"))
if (layoutId != R.layout.flash_widget_small) {
setOnClickPendingIntent(R.id.widget_btn_scan, deepLinkIntent(context, "flash://scan"))
setOnClickPendingIntent(R.id.widget_btn_receive, deepLinkIntent(context, "flash://receive"))
}
}

appWidgetManager.updateAppWidget(appWidgetId, views)
}

private fun deepLinkIntent(context: Context, uri: String): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
setPackage(context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
return PendingIntent.getActivity(
context,
uri.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
62 changes: 62 additions & 0 deletions android/app/src/main/java/com/lnflash/widget/PriceFetcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.lnflash.widget

import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.pow

/**
* Fetches a fresh BTC price using the public, unauthenticated `realtimePrice`
* GraphQL query so the widget can refresh even while the app is closed. Runs on
* a background thread (call from [FlashWidgetProvider.onUpdate]).
*/
object PriceFetcher {
// Production Flash GraphQL endpoint (app/config/galoy-instances.ts → "Main").
private const val ENDPOINT = "https://api.flashapp.me/graphql"

private const val QUERY =
"query realtimePriceUnauthed(\$currency: DisplayCurrency!) { " +
"realtimePrice(currency: \$currency) { timestamp btcSatPrice { base offset } denominatorCurrency } }"

/**
* Fetches the latest price for [previous].currencyCode and returns an updated
* snapshot (also persisted). Returns [previous] unchanged on any failure.
*/
fun fetch(previous: WidgetStore.Snapshot): WidgetStore.Snapshot {
return try {
val conn = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 12_000
readTimeout = 12_000
doOutput = true
setRequestProperty("Content-Type", "application/json")
}

val payload = JSONObject().apply {
put("query", QUERY)
put("variables", JSONObject().put("currency", previous.currencyCode))
}
conn.outputStream.use { it.write(payload.toString().toByteArray()) }

if (conn.responseCode !in 200..299) return previous
val body = conn.inputStream.bufferedReader().use { it.readText() }

val realtime = JSONObject(body)
.getJSONObject("data")
.getJSONObject("realtimePrice")
val btcSat = realtime.getJSONObject("btcSatPrice")
val base = btcSat.getDouble("base")
val offset = btcSat.getDouble("offset")

// displayCurrencyPerSat is in the display currency's MINOR units per sat.
// 1 BTC = 100,000,000 sats → major-unit price = perSat * 1e8 / 10^fractionDigits.
val displayCurrencyPerSat = base / 10.0.pow(offset)
val btcPrice = displayCurrencyPerSat * 100_000_000 / 10.0.pow(previous.fractionDigits)
val timestamp = realtime.optDouble("timestamp", previous.timestamp)

previous.copy(btcPrice = btcPrice, timestamp = timestamp)
} catch (e: Exception) {
previous
}
}
}
51 changes: 51 additions & 0 deletions android/app/src/main/java/com/lnflash/widget/WidgetBridgeModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.lnflash.widget

import android.appwidget.AppWidgetManager
import android.content.ComponentName
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap

/**
* React Native native module. The JS layer (app/utils/widget.ts) calls
* [setPriceData] on every price poll so the widget renders the user's display
* currency, then we ask AppWidgetManager to redraw all widget instances.
*/
class WidgetBridgeModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

override fun getName(): String = "WidgetBridge"

@ReactMethod
fun setPriceData(data: ReadableMap, promise: Promise) {
try {
val context = reactApplicationContext
val snapshot = WidgetStore.Snapshot(
btcPrice = if (data.hasKey("btcPrice")) data.getDouble("btcPrice") else 0.0,
currencyCode = if (data.hasKey("currencyCode")) data.getString("currencyCode") ?: "USD" else "USD",
currencySymbol = if (data.hasKey("currencySymbol")) data.getString("currencySymbol") ?: "$" else "$",
fractionDigits = if (data.hasKey("fractionDigits")) data.getInt("fractionDigits") else 2,
timestamp = if (data.hasKey("timestamp")) data.getDouble("timestamp") else 0.0,
)
WidgetStore.write(context, snapshot)

// Trigger an onUpdate for every Flash widget instance.
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
ComponentName(context, FlashWidgetProvider::class.java),
)
if (ids.isNotEmpty()) {
val intent = android.content.Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply {
setClass(context, FlashWidgetProvider::class.java)
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
context.sendBroadcast(intent)
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject("widget_error", e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.lnflash.widget

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class WidgetBridgePackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext,
): List<NativeModule> = listOf(WidgetBridgeModule(reactContext))

override fun createViewManagers(
reactContext: ReactApplicationContext,
): List<ViewManager<*, *>> = emptyList()
}
56 changes: 56 additions & 0 deletions android/app/src/main/java/com/lnflash/widget/WidgetStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.lnflash.widget

import android.content.Context

/**
* Reads/writes the price snapshot shared between the React Native app and the
* home-screen widget. On Android the widget lives in the same app process, so a
* plain private [android.content.SharedPreferences] file is enough.
*/
object WidgetStore {
private const val PREFS = "flash_widget"

const val KEY_PRICE = "btcPrice"
const val KEY_CURRENCY = "currencyCode"
const val KEY_SYMBOL = "currencySymbol"
const val KEY_FRACTION = "fractionDigits"
const val KEY_TIMESTAMP = "timestamp"

data class Snapshot(
val btcPrice: Double,
val currencyCode: String,
val currencySymbol: String,
val fractionDigits: Int,
val timestamp: Double,
) {
val hasPrice: Boolean get() = btcPrice > 0

/** e.g. "$67,231" or "$67,231.50" depending on the currency's fraction digits. */
fun formattedPrice(): String {
if (!hasPrice) return "—"
val formatted = String.format("%,.${fractionDigits}f", btcPrice)
return "$currencySymbol$formatted"
}
}

fun read(context: Context): Snapshot {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return Snapshot(
btcPrice = p.getFloat(KEY_PRICE, 0f).toDouble(),
currencyCode = p.getString(KEY_CURRENCY, "USD") ?: "USD",
currencySymbol = p.getString(KEY_SYMBOL, "$") ?: "$",
fractionDigits = p.getInt(KEY_FRACTION, 2),
timestamp = p.getFloat(KEY_TIMESTAMP, 0f).toDouble(),
)
}

fun write(context: Context, snapshot: Snapshot) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putFloat(KEY_PRICE, snapshot.btcPrice.toFloat())
.putString(KEY_CURRENCY, snapshot.currencyCode)
.putString(KEY_SYMBOL, snapshot.currencySymbol)
.putInt(KEY_FRACTION, snapshot.fractionDigits)
.putFloat(KEY_TIMESTAMP, snapshot.timestamp.toFloat())
.apply()
}
}
6 changes: 6 additions & 0 deletions android/app/src/main/res/drawable/widget_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1A1C1E" />
<corners android:radius="20dp" />
</shape>
6 changes: 6 additions & 0 deletions android/app/src/main/res/drawable/widget_button_bg.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2600C68A" />
<corners android:radius="14dp" />
</shape>
Loading
Loading