diff --git a/app.json b/app.json index b7dc09a..d287bbc 100644 --- a/app.json +++ b/app.json @@ -34,6 +34,19 @@ } } ], + [ + "@bacons/apple-targets", + { + "targets": [ + { + "type": "widget", + "name": "widget", + "directory": "targets/widget", + "icon": "./src/static/icon.png" + } + ] + } + ], ["./plugins/withCustomGradleProperties"], ["./plugins/withWalletConnectScheme"], ["./plugins/withWalletQueries"], @@ -63,6 +76,7 @@ "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "NSCameraUsageDescription": "This app uses the camera to scan QR codes for addresses.", + "NSSupportsLiveActivities": true, "LSApplicationQueriesSchemes": [ "metamask", "trust", diff --git a/docs/live-activity-backend.md b/docs/live-activity-backend.md new file mode 100644 index 0000000..0c28060 --- /dev/null +++ b/docs/live-activity-backend.md @@ -0,0 +1,260 @@ +# Backend Updates for Live Activities + +## Overview +There are 3 ways to update Live Activities: + +### 1. From the App (Local) +```typescript +// Update locally from React Native +await updateLiveActivity(activityId, 'New message from app'); +``` + +### 2. From Backend (Remote Push) +Your backend sends push notifications to update the Live Activity remotely. + +### 3. Background Fetch +App fetches data and updates locally. + +--- + +## Backend Implementation (Option 2) + +### Step 1: Get Push Token from App +```typescript +const result = await startLiveActivity('Initial message'); +console.log('Push Token:', result.pushToken); + +// Send this push token to your backend +await fetch('https://your-api.com/live-activity/register', { + method: 'POST', + body: JSON.stringify({ + activityId: result.activityId, + pushToken: result.pushToken, + userId: currentUser.id + }) +}); +``` + +### Step 2: Backend Sends Push Notification + +#### Node.js Example (using apn) +```javascript +const apn = require('apn'); + +// Configure APNs +const options = { + token: { + key: './AuthKey_XXXXXXXXXX.p8', // Your APNs auth key + keyId: 'XXXXXXXXXX', // Key ID + teamId: 'XXXXXXXXXX' // Team ID + }, + production: false // Use true for production +}; + +const apnProvider = new apn.Provider(options); + +// Send update to Live Activity +async function updateLiveActivity(pushToken, newMessage) { + const notification = new apn.Notification(); + + notification.topic = 'com.shapeShift.shapeShift.push-type.liveactivity'; + notification.pushType = 'liveactivity'; + notification.payload = { + aps: { + timestamp: Math.floor(Date.now() / 1000), + event: 'update', + 'content-state': { + message: newMessage + } + } + }; + + const result = await apnProvider.send(notification, pushToken); + console.log('APNs result:', result); +} + +// Example: Update when price changes +async function onPriceChange(userId, asset, newPrice) { + const pushToken = await db.getUserLiveActivityToken(userId); + if (pushToken) { + await updateLiveActivity( + pushToken, + `${asset} price: $${newPrice}` + ); + } +} +``` + +#### Python Example (using aioapns) +```python +from aioapns import APNs, NotificationRequest + +async def update_live_activity(push_token: str, new_message: str): + apns = APNs( + key_path='./AuthKey_XXXXXXXXXX.p8', + key_id='XXXXXXXXXX', + team_id='XXXXXXXXXX', + topic='com.shapeShift.shapeShift.push-type.liveactivity', + use_sandbox=True + ) + + request = NotificationRequest( + device_token=push_token, + message={ + 'aps': { + 'timestamp': int(time.time()), + 'event': 'update', + 'content-state': { + 'message': new_message + } + } + } + ) + + await apns.send_notification(request) + +# Example: WebSocket updates +async def on_websocket_message(user_id, data): + push_token = await get_user_push_token(user_id) + if push_token: + await update_live_activity( + push_token, + f"New transaction: {data['amount']} {data['asset']}" + ) +``` + +#### cURL Example (for testing) +```bash +# Get JWT token first +JWT_TOKEN=$(node -e " +const jwt = require('jsonwebtoken'); +const fs = require('fs'); +const key = fs.readFileSync('./AuthKey_XXXXXXXXXX.p8'); +const token = jwt.sign({}, key, { + algorithm: 'ES256', + keyid: 'XXXXXXXXXX', + issuer: 'XXXXXXXXXX' +}); +console.log(token); +") + +# Send push notification +curl -v \ + -H "authorization: bearer $JWT_TOKEN" \ + -H "apns-push-type: liveactivity" \ + -H "apns-topic: com.shapeShift.shapeShift.push-type.liveactivity" \ + --http2 \ + -d '{ + "aps": { + "timestamp": '$(date +%s)', + "event": "update", + "content-state": { + "message": "Updated from backend via cURL!" + } + } + }' \ + https://api.sandbox.push.apple.com:443/3/device/YOUR_PUSH_TOKEN_HERE +``` + +--- + +## Data Structure + +### ContentState (what you can update) +```swift +// In iOS code (already defined) +struct ContentState: Codable, Hashable { + var message: String + // Add more fields as needed: + // var price: Double + // var asset: String + // var timestamp: Date +} +``` + +### Push Payload Format +```json +{ + "aps": { + "timestamp": 1234567890, + "event": "update", + "content-state": { + "message": "Your custom message here" + } + } +} +``` + +--- + +## Real-World Use Cases + +### 1. **Crypto Price Updates** +```typescript +// App +const result = await startLiveActivity('BTC: $45,000'); + +// Backend (on price change) +await updateLiveActivity(pushToken, 'BTC: $46,200 📈'); +``` + +### 2. **Transaction Status** +```typescript +// App +const result = await startLiveActivity('Sending transaction...'); + +// Backend (on confirmation) +await updateLiveActivity(pushToken, 'Transaction confirmed! ✅'); +``` + +### 3. **DEX Swap Progress** +```typescript +// App +const result = await startLiveActivity('Swapping ETH → USDC'); + +// Backend updates +await updateLiveActivity(pushToken, 'Waiting for approval...'); +// ... later ... +await updateLiveActivity(pushToken, 'Swap complete! 🎉'); +``` + +--- + +## Frequency Limits + +- **Budget-based**: iOS gives you a limited budget for updates +- **Recommended**: Max 1-2 updates per minute +- **Best practice**: Only send updates when data actually changes + +--- + +## Testing + +1. **Local test** (from app): +```typescript +const result = await startLiveActivity('Test message'); +await new Promise(resolve => setTimeout(resolve, 3000)); +await updateLiveActivity(result.activityId, 'Updated locally!'); +``` + +2. **Backend test**: Use the cURL example above with your push token + +3. **Monitor logs**: Check Xcode console for `[Live Activity]` logs + +--- + +## APNs Setup Required + +1. Create an **APNs Auth Key** in Apple Developer Portal +2. Download the `.p8` file +3. Note your **Key ID** and **Team ID** +4. Add to your backend configuration + +--- + +## Security Notes + +- **Never expose** your `.p8` key file +- **Store push tokens securely** in your database +- **Validate** push token before sending +- **Rate limit** backend updates to prevent abuse diff --git a/eas.json b/eas.json index 24acab5..1f0feca 100644 --- a/eas.json +++ b/eas.json @@ -7,15 +7,52 @@ "development": { "autoIncrement": true, "channel": "development", - "developmentClient": true + "developmentClient": true, + "distribution": "internal", + "ios": { + "appExtensions": [ + { + "targetName": "widget", + "bundleIdentifier": "com.shapeShift.shapeShift.widget" + }, + { + "targetName": "live-activity", + "bundleIdentifier": "com.shapeShift.shapeShift.live-activity" + } + ] + } }, "preview": { "channel": "staging", - "distribution": "internal" + "distribution": "internal", + "ios": { + "appExtensions": [ + { + "targetName": "widget", + "bundleIdentifier": "com.shapeShift.shapeShift.widget" + }, + { + "targetName": "live-activity", + "bundleIdentifier": "com.shapeShift.shapeShift.live-activity" + } + ] + } }, "production": { "autoIncrement": true, - "channel": "production" + "channel": "production", + "ios": { + "appExtensions": [ + { + "targetName": "widget", + "bundleIdentifier": "com.shapeShift.shapeShift.widget" + }, + { + "targetName": "live-activity", + "bundleIdentifier": "com.shapeShift.shapeShift.live-activity" + } + ] + } }, "dapp-store": { "autoIncrement": true, diff --git a/modules/expo-live-activity/expo-module.config.json b/modules/expo-live-activity/expo-module.config.json new file mode 100644 index 0000000..ffc993a --- /dev/null +++ b/modules/expo-live-activity/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["ExpoLiveActivityModule"] + } +} diff --git a/modules/expo-live-activity/package.json b/modules/expo-live-activity/package.json new file mode 100644 index 0000000..63790b4 --- /dev/null +++ b/modules/expo-live-activity/package.json @@ -0,0 +1,5 @@ +{ + "name": "expo-live-activity", + "version": "1.0.0", + "main": "./src/index.ts" +} diff --git a/modules/expo-live-activity/src/index.ts b/modules/expo-live-activity/src/index.ts new file mode 100644 index 0000000..4d8b87a --- /dev/null +++ b/modules/expo-live-activity/src/index.ts @@ -0,0 +1,39 @@ +import { requireNativeModule } from 'expo-modules-core' + +export interface LiveActivityResult { + success: boolean + activityId?: string + pushToken?: string // Token for backend to send push updates + error?: string +} + +/** + * Start a Live Activity that will appear on the lock screen and Dynamic Island + * @param message Message to display in the Live Activity + * @returns Promise with result containing activityId and pushToken if successful + */ +export async function startLiveActivity(message: string): Promise { + const ExpoLiveActivityModule = requireNativeModule('ExpoLiveActivity') + return await ExpoLiveActivityModule.startActivity(message) +} + +/** + * Update a Live Activity with new content (local update from app) + * @param activityId The activity ID returned from startLiveActivity + * @param message New message to display + * @returns Promise with success status + */ +export async function updateLiveActivity(activityId: string, message: string): Promise { + const ExpoLiveActivityModule = requireNativeModule('ExpoLiveActivity') + return await ExpoLiveActivityModule.updateActivity(activityId, message) +} + +/** + * End a Live Activity + * @param activityId The activity ID returned from startLiveActivity + * @returns Promise with success status + */ +export async function endLiveActivity(activityId: string): Promise { + const ExpoLiveActivityModule = requireNativeModule('ExpoLiveActivity') + return await ExpoLiveActivityModule.endActivity(activityId) +} diff --git a/modules/expo-widget-bridge/expo-module.config.json b/modules/expo-widget-bridge/expo-module.config.json new file mode 100644 index 0000000..29ef7d4 --- /dev/null +++ b/modules/expo-widget-bridge/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["ExpoWidgetBridgeModule"] + } +} diff --git a/modules/expo-widget-bridge/package.json b/modules/expo-widget-bridge/package.json new file mode 100644 index 0000000..1fd0d90 --- /dev/null +++ b/modules/expo-widget-bridge/package.json @@ -0,0 +1,5 @@ +{ + "name": "expo-widget-bridge", + "version": "1.0.0", + "main": "./src/index.ts" +} diff --git a/modules/expo-widget-bridge/src/index.ts b/modules/expo-widget-bridge/src/index.ts new file mode 100644 index 0000000..304d83f --- /dev/null +++ b/modules/expo-widget-bridge/src/index.ts @@ -0,0 +1,27 @@ +import { NativeModulesProxy } from 'expo-modules-core' + +const ExpoWidgetBridge = NativeModulesProxy.ExpoWidgetBridge + +export interface Token { + id: string + symbol: string + name: string + price: number + priceChange24h: number + iconUrl?: string + sparkline?: number[] +} + +export enum TokenDataSource { + MarketCap = 'market_cap', + TradingVolume = 'trading_volume', + Watchlist = 'watchlist', +} + +export async function updateWidgetData( + tokens: Token[], + dataSource: TokenDataSource = TokenDataSource.MarketCap, +): Promise<{ success: boolean; message: string }> { + const tokensJSON = JSON.stringify(tokens) + return await ExpoWidgetBridge.updateWidgetData(tokensJSON, dataSource) +} diff --git a/package.json b/package.json index c5bf304..5876640 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@babel/core": "^7.26.0", "@babel/preset-env": "^7.19.3", "@babel/runtime": "^7.19.0", + "@bacons/apple-targets": "^3.0.5", "@react-native-community/eslint-config": "^3.1.0", "@react-native/metro-config": "0.79.1", "@tsconfig/react-native": "^2.0.2", diff --git a/src/App.tsx b/src/App.tsx index 6a3ead4..1e4d88a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ const isRunningInExpoGo = Constants.appOwnership === 'expo' import { LogBox } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { registerForPushNotificationsAsync } from './lib/notifications' +import { startLiveActivity } from '../modules/expo-live-activity/src' // disable bottom toast in app simulators - read the console instead LogBox.ignoreAllLogs() @@ -103,6 +104,21 @@ const App = () => { useEffect(() => { registerForPushNotificationsAsync() + + // Start Live Activity demo on app launch + if (Platform.OS === 'ios') { + startLiveActivity('ShapeShift Live Activity POC - This will disappear in 2 minutes') + .then(result => { + if (result.success) { + console.log('[Live Activity] Started successfully:', result.activityId) + } else { + console.log('[Live Activity] Failed to start:', result.error) + } + }) + .catch(error => { + console.error('[Live Activity] Error:', error) + }) + } }, []) useEffect(() => { diff --git a/targets/widget/Info.plist b/targets/widget/Info.plist new file mode 100644 index 0000000..48c64fa --- /dev/null +++ b/targets/widget/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + widget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/targets/widget/expo-target.config.js b/targets/widget/expo-target.config.js new file mode 100644 index 0000000..85d7135 --- /dev/null +++ b/targets/widget/expo-target.config.js @@ -0,0 +1,6 @@ +/** @type {import('@bacons/apple-targets').ConfigPlugin} */ +module.exports = { + type: 'widget', + frameworks: ['SwiftUI', 'ActivityKit', 'WidgetKit'], + deploymentTarget: '16.2', +}; diff --git a/targets/widget/index.swift b/targets/widget/index.swift new file mode 100644 index 0000000..c7b9620 --- /dev/null +++ b/targets/widget/index.swift @@ -0,0 +1,90 @@ +import WidgetKit +import SwiftUI +import ActivityKit + +@main +struct ShapeShiftWidgets: Widget { + let kind: String = "widget" + + var body: some WidgetConfiguration { + ActivityConfiguration(for: DemoActivityAttributes.self) { context in + // Lock screen/banner UI + LiveActivityView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "chart.line.uptrend.xyaxis") + .foregroundColor(.blue) + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.state.message) + .font(.caption) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + Text(context.state.message) + .font(.body) + .foregroundColor(.white) + } + } compactLeading: { + Image(systemName: "chart.line.uptrend.xyaxis") + .foregroundColor(.blue) + } compactTrailing: { + Text("ShapeShift") + .font(.caption2) + .foregroundColor(.white) + } minimal: { + Image(systemName: "chart.line.uptrend.xyaxis") + .foregroundColor(.blue) + } + } + } +} + +// Live Activity View for Lock Screen and Banner +struct LiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.title2) + .foregroundColor(.blue) + + Text("ShapeShift") + .font(.headline) + .foregroundColor(.white) + } + + Text(context.state.message) + .font(.body) + .foregroundColor(.white) + .lineLimit(2) + } + + Spacer() + } + .padding() + .background( + LinearGradient( + gradient: Gradient(colors: [Color.blue.opacity(0.3), Color.purple.opacity(0.3)]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .activityBackgroundTint(Color.black.opacity(0.8)) + } +} + +// MARK: - Activity Attributes (must match the main app) +@available(iOS 16.2, *) +struct DemoActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var message: String + } + + var name: String +} diff --git a/yarn.lock b/yarn.lock index 87a04bf..b055308 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3378,6 +3378,29 @@ __metadata: languageName: node linkType: hard +"@bacons/apple-targets@npm:^3.0.5": + version: 3.0.5 + resolution: "@bacons/apple-targets@npm:3.0.5" + dependencies: + "@bacons/xcode": 1.0.0-alpha.27 + "@react-native/normalize-colors": ^0.79.2 + debug: ^4.3.4 + glob: ^10.4.2 + checksum: 24617985142c164eb76a8f2c5c86f65f2eef6622393a87a9670d208e400257faf6e1d1d5ac8871f8f6d4b33ea620d085ebbbc7a23a1892210e4e2c94f8f445b3 + languageName: node + linkType: hard + +"@bacons/xcode@npm:1.0.0-alpha.27": + version: 1.0.0-alpha.27 + resolution: "@bacons/xcode@npm:1.0.0-alpha.27" + dependencies: + "@expo/plist": ^0.0.18 + debug: ^4.3.4 + uuid: ^8.3.2 + checksum: 39808b21ff9575baa5fa9dbcdca86d9466daa366e07665f5803406b1af269208ca8329ad1687ed41ab1df747e098dad3b41d99bdb29faa0dd04ba59483e1270c + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -4169,6 +4192,17 @@ __metadata: languageName: node linkType: hard +"@expo/plist@npm:^0.0.18": + version: 0.0.18 + resolution: "@expo/plist@npm:0.0.18" + dependencies: + "@xmldom/xmldom": ~0.7.0 + base64-js: ^1.2.3 + xmlbuilder: ^14.0.0 + checksum: 42f5743fcd2a07b55a9f048d27cf0f273510ab35dde1f7030b22dc8c30ab2cfb65c6e68f8aa58fbcfa00177fdc7c9696d0004083c9a47c36fd4ac7fea27d6ccc + languageName: node + linkType: hard + "@expo/plist@npm:^0.4.7": version: 0.4.7 resolution: "@expo/plist@npm:0.4.7" @@ -5698,6 +5732,13 @@ __metadata: languageName: node linkType: hard +"@react-native/normalize-colors@npm:^0.79.2": + version: 0.79.7 + resolution: "@react-native/normalize-colors@npm:0.79.7" + checksum: 0c9420bbacb93965c50d872ab65edc17669a51ba7404ae845a6ee51356d0ef5c616c41782bb7b0af7b795f0a63e579ae28145450788fbcf053abf423038c2389 + languageName: node + linkType: hard + "@react-native/virtualized-lists@npm:0.81.4": version: 0.81.4 resolution: "@react-native/virtualized-lists@npm:0.81.4" @@ -7245,6 +7286,13 @@ __metadata: languageName: node linkType: hard +"@xmldom/xmldom@npm:~0.7.0": + version: 0.7.13 + resolution: "@xmldom/xmldom@npm:0.7.13" + checksum: b4054078530e5fa8ede9677425deff0fce6d965f4c477ca73f8490d8a089e60b8498a15560425a1335f5ff99ecb851ed2c734b0a9a879299a5694302f212f37a + languageName: node + linkType: hard + "JSONStream@npm:^1.3.5": version: 1.3.5 resolution: "JSONStream@npm:1.3.5" @@ -16472,6 +16520,7 @@ __metadata: "@babel/core": ^7.26.0 "@babel/preset-env": ^7.19.3 "@babel/runtime": ^7.19.0 + "@bacons/apple-targets": ^3.0.5 "@react-hook/async": ^3.1.1 "@react-native-async-storage/async-storage": 2.2.0 "@react-native-community/eslint-config": ^3.1.0 @@ -17995,6 +18044,13 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:^14.0.0": + version: 14.0.0 + resolution: "xmlbuilder@npm:14.0.0" + checksum: 9e93d3c73957dbb21acde63afa5d241b19057bdbdca9d53534d8351e70f1d5c9db154e3ca19bd3e9ea84c082539ab6e7845591c8778a663e8b5d3470d5427a8b + languageName: node + linkType: hard + "xmlbuilder@npm:^15.1.1": version: 15.1.1 resolution: "xmlbuilder@npm:15.1.1"