From 8e68fcf60ff759c79fa2cc430390a17bcf60ea89 Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 1 Apr 2025 00:03:41 -0400 Subject: [PATCH] Fix Android Amber signer integration and cleanup repo structure - Added Amber external signer integration for secure private key management on Android - Fixed authentication issues and NIP-55 protocol implementation - Added comprehensive documentation in amber-integration-fixes.md - Moved android_backup to external location to keep repo clean - Updated .gitignore to exclude APK files --- .gitignore | 5 +- CHANGELOG.md | 17 +- android/app/src/main/AndroidManifest.xml | 25 +- .../java/com/powr/app/AmberSignerModule.kt | 280 ++++++++++++++++++ .../java/com/powr/app/AmberSignerPackage.kt | 22 ++ .../main/java/com/powr/app/MainApplication.kt | 6 +- components/sheets/NostrLoginSheet.tsx | 125 ++++---- .../nostr/amber-integration-fixes.md | 94 ++++++ lib/hooks/useNDK.ts | 31 +- lib/signers/NDKAmberSigner.ts | 115 ++++--- stores/workoutStore.ts | 112 +++++-- utils/ExternalSignerUtils.ts | 172 +++++++---- 12 files changed, 795 insertions(+), 209 deletions(-) create mode 100644 android/app/src/main/java/com/powr/app/AmberSignerModule.kt create mode 100644 android/app/src/main/java/com/powr/app/AmberSignerPackage.kt create mode 100644 docs/technical/nostr/amber-integration-fixes.md diff --git a/.gitignore b/.gitignore index a689687..5082fae 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ web-build/ # Prebuild folders - addressing workflow conflict # For CNG/Prebuild with EAS Build -/android +# Temporarily allowing Android files for EAS build +# /android /ios # Metro @@ -44,3 +45,5 @@ yarn-error.* expo-env.d.ts # @end expo-cli +# Exclude build APK files +*.apk diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a7b94..4fcb841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +All notable changes to the POWR project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added @@ -10,8 +14,15 @@ - Exposed new authentication method through useNDKAuth hook - Added "Sign with Amber" option to login screen - Added comprehensive documentation in docs/technical/nostr/external-signers.md - + - Added technical documentation in docs/technical/nostr/amber-integration-fixes.md + ### Fixed +- Android: Fixed Amber external signer integration issues + - Added extensive logging to better diagnose communication issues + - Improved error handling in `AmberSignerModule.kt` + - Fixed intent construction to better follow NIP-55 protocol + - Enhanced response handling with checks for URI parameters and intent extras + - Added POWR-specific event kinds (1301, 33401, 33402) to permission requests - Authentication state management issues - Fixed hook ordering inconsistencies when switching between authenticated and unauthenticated states - Enhanced profile overview screen with consistent hook calling patterns @@ -30,10 +41,6 @@ - TestFlight preparation: Hid development-only Programs tab in production builds - TestFlight preparation: Removed debug UI and console logs from social feed in production builds -All notable changes to the POWR project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). # Changelog - March 28, 2025 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6f0e0fa..cf24696 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + @@ -10,11 +12,21 @@ + + + + + + + + - - + + + + @@ -26,7 +38,14 @@ + + + + + + + - \ No newline at end of file + diff --git a/android/app/src/main/java/com/powr/app/AmberSignerModule.kt b/android/app/src/main/java/com/powr/app/AmberSignerModule.kt new file mode 100644 index 0000000..160cd05 --- /dev/null +++ b/android/app/src/main/java/com/powr/app/AmberSignerModule.kt @@ -0,0 +1,280 @@ +package com.powr.app + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import com.facebook.react.bridge.* +import com.facebook.react.modules.core.DeviceEventManagerModule +import org.json.JSONArray +import org.json.JSONObject + +/** + * AmberSignerModule - React Native module that provides interfaces to communicate with Amber signer + * Implements NIP-55 for Android: https://github.com/nostr-protocol/nips/blob/master/55.md + */ +class AmberSignerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), ActivityEventListener { + private val TAG = "AmberSignerModule" + private var pendingPromise: Promise? = null + private val AMBER_PACKAGE_NAME = "com.greenart7c3.nostrsigner" + private val NOSTRSIGNER_SCHEME = "nostrsigner:" + + init { + reactContext.addActivityEventListener(this) + } + + override fun getName(): String { + return "AmberSignerModule" + } + + @ReactMethod + fun isExternalSignerInstalled(promise: Promise) { + val context = reactApplicationContext + val intent = Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse(NOSTRSIGNER_SCHEME) + } + val infos = context.packageManager.queryIntentActivities(intent, 0) + Log.d(TAG, "External signer installed: ${infos.size > 0}") + promise.resolve(infos.size > 0) + } + + @ReactMethod + fun requestPublicKey(permissions: ReadableArray?, promise: Promise) { + Log.d(TAG, "requestPublicKey called with permissions: $permissions") + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(NOSTRSIGNER_SCHEME)) + intent.`package` = AMBER_PACKAGE_NAME + intent.putExtra("type", "get_public_key") + + // Convert permissions to JSON if provided + if (permissions != null) { + intent.putExtra("permissions", convertPermissionsToJson(permissions)) + } + + try { + pendingPromise = promise + val activity = currentActivity + if (activity != null) { + Log.d(TAG, "Starting activity for result") + activity.startActivityForResult(intent, REQUEST_CODE_SIGN) + } else { + Log.e(TAG, "Activity doesn't exist") + promise.reject("E_ACTIVITY_DOES_NOT_EXIST", "Activity doesn't exist") + pendingPromise = null + } + } catch (e: Exception) { + Log.e(TAG, "Error launching Amber activity: ${e.message}") + promise.reject("E_LAUNCH_ERROR", "Error launching Amber: ${e.message}") + pendingPromise = null + } + } + + @ReactMethod + fun signEvent(eventJson: String, currentUserPubkey: String, eventId: String?, promise: Promise) { + Log.d(TAG, "signEvent called - eventJson length: ${eventJson.length}, npub: $currentUserPubkey") + + // For NIP-55, we need to create a URI with the event JSON + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(NOSTRSIGNER_SCHEME)) + intent.`package` = AMBER_PACKAGE_NAME + intent.putExtra("type", "sign_event") + intent.putExtra("event", eventJson) // Event data as extra instead of URI + + // Add event ID for tracking (optional but useful) + if (eventId != null) { + intent.putExtra("id", eventId) + } + + // Send the current logged in user npub + intent.putExtra("current_user", currentUserPubkey) + + // Add flags to handle multiple signing requests + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + + try { + pendingPromise = promise + val activity = currentActivity + if (activity != null) { + Log.d(TAG, "Starting activity for result") + activity.startActivityForResult(intent, REQUEST_CODE_SIGN) + } else { + Log.e(TAG, "Activity doesn't exist") + promise.reject("E_ACTIVITY_DOES_NOT_EXIST", "Activity doesn't exist") + pendingPromise = null + } + } catch (e: Exception) { + Log.e(TAG, "Error launching Amber activity: ${e.message}") + promise.reject("E_LAUNCH_ERROR", "Error launching Amber: ${e.message}") + pendingPromise = null + } + } + + // Convert ReadableArray of permissions to JSON string + private fun convertPermissionsToJson(permissions: ReadableArray): String { + val jsonArray = JSONArray() + for (i in 0 until permissions.size()) { + val permission = permissions.getMap(i) + val jsonPermission = JSONObject() + jsonPermission.put("type", permission.getString("type")) + if (permission.hasKey("kind")) { + jsonPermission.put("kind", permission.getInt("kind")) + } + jsonArray.put(jsonPermission) + } + return jsonArray.toString() + } + + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + Log.d(TAG, "onActivityResult - requestCode: $requestCode, resultCode: $resultCode, data: $data") + + if (requestCode == REQUEST_CODE_SIGN) { + val promise = pendingPromise + pendingPromise = null + + if (promise == null) { + Log.w(TAG, "No pending promise for activity result") + return + } + + if (resultCode != Activity.RESULT_OK) { + Log.e(TAG, "Activity result not OK: $resultCode") + promise.reject("E_CANCELLED", "User cancelled or activity failed") + return + } + + if (data == null) { + Log.e(TAG, "No data returned from activity") + promise.reject("E_NO_DATA", "No data returned from Amber") + return + } + + // Log all extras for debugging + val extras = data.extras + if (extras != null) { + Log.d(TAG, "Intent extras:") + for (key in extras.keySet()) { + Log.d(TAG, " $key: ${extras.get(key)}") + } + } + + try { + // First try to get data from extras + val signature = data.getStringExtra("signature") + val id = data.getStringExtra("id") + val event = data.getStringExtra("event") + val packageName = data.getStringExtra("package") + + val response = WritableNativeMap() + + if (signature != null) { + response.putString("signature", signature) + } + + if (id != null) { + response.putString("id", id) + } + + if (event != null) { + response.putString("event", event) + } + + if (packageName != null) { + response.putString("packageName", packageName) + } + + // If no extras, try to parse from URI + val uri = data.data + if (uri != null) { + // Check if we already have data from extras + if (signature == null) { + val uriSignature = uri.getQueryParameter("signature") + if (uriSignature != null) { + response.putString("signature", uriSignature) + } + } + + if (id == null) { + val uriId = uri.getQueryParameter("id") + if (uriId != null) { + response.putString("id", uriId) + } + } + + if (event == null) { + val uriEvent = uri.getQueryParameter("event") + if (uriEvent != null) { + response.putString("event", uriEvent) + } + } + + if (packageName == null) { + val uriPackage = uri.getQueryParameter("package") + if (uriPackage != null) { + response.putString("packageName", uriPackage) + } + } + + // Check if we received a JSON array of results + val uriSignature = uri.getQueryParameter("signature") + if (uriSignature?.startsWith("[") == true && uriSignature.endsWith("]")) { + try { + val resultsArray = JSONArray(uriSignature) + val results = WritableNativeArray() + + for (i in 0 until resultsArray.length()) { + val resultObj = resultsArray.getJSONObject(i) + val resultMap = WritableNativeMap() + + if (resultObj.has("signature")) { + resultMap.putString("signature", resultObj.getString("signature")) + } + + if (resultObj.has("id")) { + resultMap.putString("id", resultObj.getString("id")) + } + + if (resultObj.has("package")) { + val pkg = resultObj.optString("package") + if (pkg != "null") { + resultMap.putString("packageName", pkg) + } + } + + results.pushMap(resultMap) + } + + response.putArray("results", results) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse JSON array result: ${e.message}") + // Continue with normal handling if parsing fails + } + } + } + + // Check if we got any data + if (!response.hasKey("signature") && !response.hasKey("results")) { + Log.e(TAG, "No signature data in response") + promise.reject("E_NO_DATA", "No signature data returned from Amber") + return + } + + // Log full response for debugging + Log.d(TAG, "Amber response: $response") + promise.resolve(response) + } catch (e: Exception) { + Log.e(TAG, "Error processing Amber response: ${e.message}") + promise.reject("E_PROCESSING_ERROR", "Error processing Amber response: ${e.message}") + } + } + } + + override fun onNewIntent(intent: Intent?) { + // Not used for our implementation + } + + companion object { + private const val REQUEST_CODE_SIGN = 9001 + } +} diff --git a/android/app/src/main/java/com/powr/app/AmberSignerPackage.kt b/android/app/src/main/java/com/powr/app/AmberSignerPackage.kt new file mode 100644 index 0000000..ac52abb --- /dev/null +++ b/android/app/src/main/java/com/powr/app/AmberSignerPackage.kt @@ -0,0 +1,22 @@ +package com.powr.app + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +/** + * ReactPackage implementation for AmberSigner module + * This package is registered in MainApplication.kt + */ +class AmberSignerPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(AmberSignerModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List>> { + return emptyList() + } +} diff --git a/android/app/src/main/java/com/powr/app/MainApplication.kt b/android/app/src/main/java/com/powr/app/MainApplication.kt index 453301f..a7ace10 100644 --- a/android/app/src/main/java/com/powr/app/MainApplication.kt +++ b/android/app/src/main/java/com/powr/app/MainApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.res.Configuration import com.facebook.react.PackageList +import com.powr.app.AmberSignerPackage import com.facebook.react.ReactApplication import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage @@ -23,8 +24,9 @@ class MainApplication : Application(), ReactApplication { object : DefaultReactNativeHost(this) { override fun getPackages(): List { val packages = PackageList(this).packages - // Packages that cannot be autolinked yet can be added manually here, for example: + // Packages that cannot be autolinked yet can be added manually here for example: // packages.add(new MyReactNativePackage()); + packages.add(AmberSignerPackage()) return packages } @@ -44,7 +46,7 @@ class MainApplication : Application(), ReactApplication { super.onCreate() SoLoader.init(this, OpenSourceMergedSoMapping) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. + // If you opted-in for the New Architecture we load the native entry point for this app. load() } ApplicationLifecycleDispatcher.onApplicationCreate(this) diff --git a/components/sheets/NostrLoginSheet.tsx b/components/sheets/NostrLoginSheet.tsx index a6c68f7..58867d6 100644 --- a/components/sheets/NostrLoginSheet.tsx +++ b/components/sheets/NostrLoginSheet.tsx @@ -1,4 +1,3 @@ -// components/sheets/NostrLoginSheet.tsx import React, { useState, useEffect } from 'react'; import { View, ActivityIndicator, Modal, TouchableOpacity, Platform } from 'react-native'; import { Text } from '@/components/ui/text'; @@ -23,7 +22,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) // State for external signer availability const [isExternalSignerAvailable, setIsExternalSignerAvailable] = useState(false); - + // Check if external signer is available useEffect(() => { async function checkExternalSigner() { @@ -37,7 +36,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) } } } - + checkExternalSigner(); }, []); @@ -63,7 +62,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) setError(null); try { const success = await login(privateKey); - + if (success) { setPrivateKey(''); onClose(); @@ -75,37 +74,58 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) setError(err instanceof Error ? err.message : 'An unexpected error occurred'); } }; - + // Handle login with Amber (external signer) const handleAmberLogin = async () => { setError(null); - + try { console.log('Attempting to login with Amber...'); - - try { - // Request public key from Amber - // This will throw an error because the native module isn't implemented - // but the TypeScript interface is ready for when it is - const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey(); + + // Define default permissions to request + const defaultPermissions = [ + { type: 'sign_event' }, // Basic event signing + { type: 'sign_event', kind: 0 }, // Profile metadata + { type: 'sign_event', kind: 1 }, // Notes + { type: 'sign_event', kind: 3 }, // Contacts + { type: 'sign_event', kind: 4 }, // DMs + { type: 'sign_event', kind: 6 }, // Reposts + { type: 'sign_event', kind: 7 }, // Reactions + { type: 'sign_event', kind: 9734 }, // Zaps + { type: 'sign_event', kind: 1111 }, // Comments (NIP-22) - // Login with the external signer - const success = await loginWithExternalSigner(pubkey, packageName); - - if (success) { - onClose(); - } else { - setError('Failed to login with Amber'); - } - } catch (requestError) { - // Since the native implementation is not available yet, - // we show a more user-friendly error message - console.error('Amber requestPublicKey error:', requestError); - setError("Amber signing requires a native module implementation. The interface is ready but the native code needs to be completed."); + // POWR-specific event kinds + { type: 'sign_event', kind: 1301 }, // Workout Record (1301) + { type: 'sign_event', kind: 33401 }, // Exercise Template (33401) + { type: 'sign_event', kind: 33402 }, // Workout Template (33402) + ]; + + // Request public key from Amber + const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey(defaultPermissions); + + // Login with the external signer + const success = await loginWithExternalSigner(pubkey, packageName); + + if (success) { + onClose(); + } else { + setError('Failed to login with Amber'); } } catch (err) { console.error('Amber login error:', err); - setError(err instanceof Error ? err.message : 'An unexpected error with Amber'); + + // Provide helpful error messages based on common issues + if (err instanceof Error) { + if (err.message.includes('Failed to get public key')) { + setError('Unable to get key from Amber. Please make sure Amber is installed and try again.'); + } else if (err.message.includes('User cancelled')) { + setError('Login cancelled by user.'); + } else { + setError(err.message); + } + } else { + setError('An unexpected error occurred with Amber.'); + } } }; @@ -124,30 +144,31 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) - + {/* External signer option (Android only) */} {Platform.OS === 'android' && ( - + <> + + - or - + )} - - - or - - + Enter your Nostr private key (nsec) - + {error && ( {error} )} - + - - + - + diff --git a/docs/technical/nostr/amber-integration-fixes.md b/docs/technical/nostr/amber-integration-fixes.md new file mode 100644 index 0000000..5c4b336 --- /dev/null +++ b/docs/technical/nostr/amber-integration-fixes.md @@ -0,0 +1,94 @@ +# Amber Signer Integration Fixes + +## Issue Summary + +The POWR app was experiencing issues when trying to integrate with the Amber external signer on Android. Specifically, the app was receiving "No data returned from Amber" errors when attempting to request a public key or sign events. + +## Root Causes and Fixes + +### 1. Improved Native Module Implementation + +The primary issue was in the `AmberSignerModule.kt` implementation, which handles the communication between the React Native app and the Amber app: + +- **Enhanced Intent Construction**: Modified how we create the intent to Amber to better follow NIP-55 protocol requirements. + - For `signEvent`, we now pass the event as an extra rather than in the URI itself to prevent potential payload size issues. + +- **Implemented Better Response Handling**: + - Added logic to check both intent extras and URI parameters for responses + - Added validation to ensure signature data exists before resolving promises + - Improved error reporting with more specific messages + +- **Added Extensive Logging**: + - Added detailed log statements throughout to help diagnose issues + - Log extras received from intent responses + - Log the entire response object before resolving + +### 2. Fixed JavaScript Implementation + +Several minor issues in the JavaScript files were also fixed: + +- **ExternalSignerUtils.ts**: + - Added additional logging to track request/response flow + - Improved error handling with more descriptive messages + - Added checks for null/undefined responses + +- **NostrLoginSheet.tsx**: + - Fixed syntax issues with missing commas in function parameters + - Added comprehensive Nostr event kind permissions including: + - Standard Nostr kinds: 0 (metadata), 1 (notes), 3 (contacts), 4 (DMs), 6 (reposts), 7 (reactions), 9734 (zaps) + - Comments kind 1111 (as defined in NIP-22) + - POWR-specific workout event kinds: + - Kind 1301: Workout Records - Stores completed workout sessions + - Kind 33401: Exercise Templates - Defines reusable exercise definitions with detailed form instructions + - Kind 33402: Workout Templates - Defines complete workout plans with associated exercises + +### 3. POWR-Specific Nostr Event Kinds + +Our app uses custom Nostr event kinds as defined in our exercise NIP (NIP-4e) proposal: + +#### Exercise Template (kind: 33401) +These are parameterized replaceable events that define reusable exercise definitions with: +- Title, equipment type, and difficulty level +- Format parameters (weight, reps, RPE, set_type) +- Units for each parameter +- Optional media demonstrations +- Detailed form instructions in the content + +#### Workout Template (kind: 33402) +These are parameterized replaceable events that define workout plans with: +- Title and workout type (strength, circuit, EMOM, AMRAP) +- Exercise references with prescribed parameters +- Optional rounds, duration, intervals, and rest times +- Workout instructions in the content + +#### Workout Record (kind: 1301) +These are standard events that record completed workouts with: +- Start and end timestamps +- Exercises performed with actual values +- Completion status and rounds completed +- Optional reference to the template used +- Personal records achieved +- Notes about the workout experience + +## Testing the Fix + +To verify that the Amber integration is working correctly: + +1. Make sure you have the Amber app installed on your Android device +2. In the POWR app, try to log in using the "Sign with Amber" button +3. Amber should launch and ask for permission to share your public key and sign the specified event kinds +4. After granting permission, you should be logged in successfully + +If you encounter any issues, the extensive logging added should help identify the specific point of failure. + +## Common Issues and Troubleshooting + +- **Amber Not Launching**: Make sure the Amber app is installed and up to date. The app should be available at package name `com.greenart7c3.nostrsigner`. + +- **Permission Denied**: Ensure the Android app has been granted necessary permissions. + +- **Signature Missing**: If Amber returns data but the signature is missing, check the Amber app version and ensure it supports the NIP-55 protocol. + +- **App Crash During Launch**: This could indicate an issue with the intent construction. Check the logs for details. + +The improvements made to the error handling and logging should make it much easier to diagnose any remaining issues. diff --git a/lib/hooks/useNDK.ts b/lib/hooks/useNDK.ts index 24aa472..17a2f28 100644 --- a/lib/hooks/useNDK.ts +++ b/lib/hooks/useNDK.ts @@ -1,4 +1,3 @@ -// lib/hooks/useNDK.ts import { useEffect } from 'react'; import { useNDKStore } from '@/lib/stores/ndk'; import type { NDKUser, NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile'; @@ -11,13 +10,13 @@ export function useNDK() { error: state.error, init: state.init })); - + useEffect(() => { if (!ndk && !isLoading) { init(); } }, [ndk, isLoading, init]); - + return { ndk, isLoading, error }; } @@ -28,23 +27,23 @@ export function useNDKCurrentUser() { isAuthenticated: state.isAuthenticated, isLoading: state.isLoading })); - - return { - currentUser, + + return { + currentUser, isAuthenticated, - isLoading + isLoading }; } // Hook for authentication actions export function useNDKAuth() { - const { - login, - loginWithExternalSigner, - logout, - generateKeys, - isAuthenticated, - isLoading + const { + login, + loginWithExternalSigner, + logout, + generateKeys, + isAuthenticated, + isLoading } = useNDKStore(state => ({ login: state.login, loginWithExternalSigner: state.loginWithExternalSigner, @@ -53,7 +52,7 @@ export function useNDKAuth() { isAuthenticated: state.isAuthenticated, isLoading: state.isLoading })); - + return { login, loginWithExternalSigner, @@ -70,7 +69,7 @@ export function useNDKEvents() { publishEvent: state.publishEvent, fetchEventsByFilter: state.fetchEventsByFilter })); - + return { publishEvent, fetchEventsByFilter diff --git a/lib/signers/NDKAmberSigner.ts b/lib/signers/NDKAmberSigner.ts index 3745cde..753b59c 100644 --- a/lib/signers/NDKAmberSigner.ts +++ b/lib/signers/NDKAmberSigner.ts @@ -1,28 +1,28 @@ -// lib/signers/NDKAmberSigner.ts import NDK, { type NDKSigner, type NDKUser, type NDKEncryptionScheme } from '@nostr-dev-kit/ndk-mobile'; -import { Platform, Linking } from 'react-native'; +import { Platform } from 'react-native'; import type { NostrEvent } from 'nostr-tools'; -import ExternalSignerUtils from '@/utils/ExternalSignerUtils'; +import ExternalSignerUtils, { NIP55Permission } from '@/utils/ExternalSignerUtils'; +import { nip19 } from 'nostr-tools'; /** * NDK Signer implementation for Amber (NIP-55 compatible external signer) - * + * * This signer delegates signing operations to the Amber app on Android * through the use of Intent-based communication as defined in NIP-55. - * - * Note: This is Android-specific and will need native module support. + * + * Note: This is Android-specific and requires the AmberSignerModule native module. */ export class NDKAmberSigner implements NDKSigner { /** * The public key of the user in hex format */ private pubkey: string; - + /** * The package name of the Amber app */ - private packageName: string | null = 'com.greenart7c3.nostrsigner'; - + private packageName: string; + /** * Whether this signer can sign events */ @@ -30,11 +30,11 @@ export class NDKAmberSigner implements NDKSigner { /** * Constructor - * + * * @param pubkey The user's public key (hex) * @param packageName Optional Amber package name (default: com.greenart7c3.nostrsigner) */ - constructor(pubkey: string, packageName: string | null = 'com.greenart7c3.nostrsigner') { + constructor(pubkey: string, packageName: string = 'com.greenart7c3.nostrsigner') { this.pubkey = pubkey; this.packageName = packageName; this.canSign = Platform.OS === 'android'; @@ -43,7 +43,7 @@ export class NDKAmberSigner implements NDKSigner { /** * Implement blockUntilReady required by NDKSigner interface * Amber signer is always ready once initialized - * + * * @returns The user this signer represents */ async blockUntilReady(): Promise { @@ -53,7 +53,7 @@ export class NDKAmberSigner implements NDKSigner { /** * Get user's NDK user object - * + * * @returns An NDKUser representing this user */ async user(): Promise { @@ -65,7 +65,7 @@ export class NDKAmberSigner implements NDKSigner { /** * Get user's public key - * + * * @returns The user's public key in hex format */ async getPublicKey(): Promise { @@ -74,10 +74,9 @@ export class NDKAmberSigner implements NDKSigner { /** * Sign an event using Amber - * - * This will need to be implemented with native Android modules to handle - * Intent-based communication with Amber. - * + * + * Uses the native module to send an intent to Amber for signing. + * * @param event The event to sign * @returns The signature for the event * @throws Error if not on Android or signing fails @@ -87,24 +86,28 @@ export class NDKAmberSigner implements NDKSigner { throw new Error('NDKAmberSigner is only available on Android'); } - // This is a placeholder for the actual native implementation - // In a full implementation, this would use the React Native bridge to call - // native Android code that would handle the Intent-based communication with Amber - - console.log('Amber signing event:', event); - - // Placeholder implementation - in a real implementation, we would: - // 1. Convert the event to JSON - // 2. Create an Intent to send to Amber - // 3. Wait for the result from Amber - // 4. Extract the signature from the result - - throw new Error('NDKAmberSigner.sign() not implemented'); + try { + // Get the npub representation of the hex pubkey + const npub = nip19.npubEncode(this.pubkey); + + // Use ExternalSignerUtils to sign the event + const response = await ExternalSignerUtils.signEvent(event, npub); + + if (!response.signature) { + throw new Error('No signature returned from Amber'); + } + + return response.signature; + } catch (e: unknown) { + console.error('Error signing with Amber:', e); + const errorMessage = e instanceof Error ? e.message : 'Unknown error'; + throw new Error(`Failed to sign event with Amber: ${errorMessage}`); + } } /** * Check if this signer is capable of creating encrypted direct messages - * + * * @returns Always returns false as NIP-04/NIP-44 encryption needs to be implemented separately */ get supportsEncryption(): boolean { @@ -129,24 +132,48 @@ export class NDKAmberSigner implements NDKSigner { /** * Static method to request public key from Amber - * This needs to be implemented with native modules. - * - * @returns Promise with public key and package name + * Uses the ExternalSignerUtils to communicate with Amber. + * + * @param permissions Optional array of permissions to request + * @returns Promise with public key (hex) and package name */ - static async requestPublicKey(): Promise<{pubkey: string, packageName: string}> { + static async requestPublicKey( + permissions: NIP55Permission[] = [] + ): Promise<{pubkey: string, packageName: string}> { if (Platform.OS !== 'android') { throw new Error('NDKAmberSigner is only available on Android'); } - // This is a placeholder for the actual native implementation - // In a full implementation, this would launch an Intent to get the user's public key from Amber - - // Since this requires native code implementation, we'll throw an error - // indicating that this functionality needs to be implemented with native modules - throw new Error('NDKAmberSigner.requestPublicKey() requires native implementation'); + try { + // Request public key from Amber + console.log('[NDKAmberSigner] Requesting public key from Amber'); + const result = await ExternalSignerUtils.requestPublicKey(permissions); + console.log('[NDKAmberSigner] Received result from ExternalSignerUtils:', result); - // When implemented, this would return: - // return { pubkey: 'hex_pubkey_from_amber', packageName: 'com.greenart7c3.nostrsigner' }; + // Convert npub to hex if needed + let pubkeyHex = result.pubkey; + if (pubkeyHex.startsWith('npub')) { + try { + // Decode the npub to get the hex pubkey + const decoded = nip19.decode(pubkeyHex); + if (decoded.type === 'npub') { + pubkeyHex = decoded.data as string; + } + } catch (e) { + console.error('Error decoding npub:', e); + throw new Error('Invalid npub returned from Amber'); + } + } + + return { + pubkey: pubkeyHex, + packageName: result.packageName + }; + } catch (e: unknown) { + console.error('Error requesting public key from Amber:', e); + const errorMessage = e instanceof Error ? e.message : 'Unknown error'; + throw new Error(`Failed to get public key from Amber: ${errorMessage}`); + } } } diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts index 5fb13ff..e9bb661 100644 --- a/stores/workoutStore.ts +++ b/stores/workoutStore.ts @@ -253,40 +253,88 @@ const useWorkoutStoreBase = create((_, reject) => { + setTimeout(() => reject(new Error('Signing timeout after 15 seconds')), 15000); + }); + + try { + // Race the sign operation against a timeout + await Promise.race([signPromise, signTimeout]); + console.log('Event signed successfully'); + + // Add timeout for publishing as well + const publishPromise = event.publish(); + const publishTimeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Publishing timeout after 15 seconds')), 15000); + }); + + await Promise.race([publishPromise, publishTimeout]); + console.log('Successfully published workout event'); + + // Handle social share if selected + if (options.shareOnSocial && options.socialMessage) { + try { + const socialEventData = NostrWorkoutService.createSocialShareEvent( + event.id, + options.socialMessage + ); + + // Create an NDK event for the social share + const socialEvent = new NDKEvent(ndk as any); + socialEvent.kind = socialEventData.kind; + socialEvent.content = socialEventData.content; + socialEvent.tags = socialEventData.tags || []; + socialEvent.created_at = socialEventData.created_at; + + // Sign with timeout + const socialSignPromise = socialEvent.sign(); + const socialSignTimeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Social signing timeout after 15 seconds')), 15000); + }); + + await Promise.race([socialSignPromise, socialSignTimeout]); + + // Publish with timeout + const socialPublishPromise = socialEvent.publish(); + const socialPublishTimeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Social publishing timeout after 15 seconds')), 15000); + }); + + await Promise.race([socialPublishPromise, socialPublishTimeout]); + console.log('Successfully published social share'); + } catch (socialError) { + console.error('Error publishing social share:', socialError); + // Continue with workout completion even if social sharing fails + } + } + } catch (error) { + const signError = error as Error; + console.error('Error signing or publishing event:', signError); + + // Specific handling for timeout errors to give user better feedback + if (signError.message?.includes('timeout')) { + console.warn('The signing operation timed out. This may be due to an issue with the external signer.'); + } + + // Continue with workout completion even though publishing failed + } + } catch (eventCreationError) { + console.error('Error creating event:', eventCreationError); + // Continue with workout completion, but log the error } } catch (publishError) { console.error('Error publishing to Nostr:', publishError); @@ -922,4 +970,4 @@ if (typeof module !== 'undefined' && 'hot' in module) { console.log('Workout timer cleared on hot reload'); } }); -} \ No newline at end of file +} diff --git a/utils/ExternalSignerUtils.ts b/utils/ExternalSignerUtils.ts index df369a2..563b90a 100644 --- a/utils/ExternalSignerUtils.ts +++ b/utils/ExternalSignerUtils.ts @@ -1,5 +1,23 @@ -// utils/ExternalSignerUtils.ts -import { Platform } from 'react-native'; +import { Platform, NativeModules } from 'react-native'; + +// Interface for NIP-55 permission +export interface NIP55Permission { + type: string; + kind?: number; +} + +// Interface for the response from the native module +export interface AmberResponse { + signature?: string; + id?: string; + event?: string; + packageName?: string; + results?: Array<{ + signature?: string; + id?: string; + packageName?: string; + }>; +} /** * Utility functions for interacting with external Nostr signers (Android only) @@ -8,75 +26,121 @@ import { Platform } from 'react-native'; class ExternalSignerUtils { /** * Check if an external signer is installed (Amber) - * - * Note: This needs to be implemented in native code since it requires - * access to Android's PackageManager to query for activities that can - * handle the nostrsigner: scheme. This is a placeholder for the TypeScript - * interface. - * + * + * Uses the native Android module to check if Amber is installed. + * * @returns {Promise} True if an external signer is available */ - static isExternalSignerInstalled(): Promise { - // This would need to be implemented with native modules - // For now, we'll return false if not on Android + static async isExternalSignerInstalled(): Promise { if (Platform.OS !== 'android') { - return Promise.resolve(false); + return false; } - // TODO: Add actual implementation that calls native code: - // In native Android code: - // val intent = Intent().apply { - // action = Intent.ACTION_VIEW - // data = Uri.parse("nostrsigner:") - // } - // val infos = context.packageManager.queryIntentActivities(intent, 0) - // return infos.size > 0 + try { + const { AmberSignerModule } = NativeModules; + if (AmberSignerModule) { + return await AmberSignerModule.isExternalSignerInstalled(); + } + } catch (e) { + console.error('Error checking for external signer:', e); + } - // Placeholder implementation - this should be replaced with actual native code check - return Promise.resolve(false); + return false; + } + + /** + * Request public key from external signer + * + * @param {Array} permissions Default permissions to request + * @returns {Promise<{pubkey: string, packageName: string}>} Public key and package name of the signer + */ + static async requestPublicKey( + permissions: Array = [] + ): Promise<{pubkey: string, packageName: string}> { + if (Platform.OS !== 'android') { + throw new Error('External signers are only supported on Android'); + } + + try { + const { AmberSignerModule } = NativeModules; + if (!AmberSignerModule) { + throw new Error('AmberSignerModule not available'); + } + + console.log('[ExternalSignerUtils] Calling AmberSignerModule.requestPublicKey'); + const response = await AmberSignerModule.requestPublicKey(permissions); + console.log('[ExternalSignerUtils] Response from AmberSignerModule:', response); + + if (!response) { + throw new Error('No response received from Amber'); + } + + if (!response.signature) { + throw new Error(`Invalid response from Amber: ${JSON.stringify(response)}`); + } + + return { + pubkey: response.signature, // Amber returns npub in the signature field + packageName: response.packageName || 'com.greenart7c3.nostrsigner' + }; + } catch (e) { + console.error('Error requesting public key:', e); + throw e; + } + } + + /** + * Sign an event using external signer + * + * @param {Object} event The event to sign + * @param {string} currentUserPubkey The current user's public key + * @param {string} eventId Optional ID for tracking the event + * @returns {Promise} The response from Amber including signature + */ + static async signEvent( + event: any, + currentUserPubkey: string, + eventId?: string + ): Promise { + if (Platform.OS !== 'android') { + throw new Error('External signers are only supported on Android'); + } + + try { + const { AmberSignerModule } = NativeModules; + if (!AmberSignerModule) { + throw new Error('AmberSignerModule not available'); + } + + const eventJson = JSON.stringify(event); + console.log('[ExternalSignerUtils] Calling AmberSignerModule.signEvent'); + const response = await AmberSignerModule.signEvent( + eventJson, + currentUserPubkey, + eventId || event.id || `event-${Date.now()}` + ); + console.log('[ExternalSignerUtils] Response from AmberSignerModule.signEvent:', response); + + return response; + } catch (e) { + console.error('Error signing event:', e); + throw e; + } } /** * Format permissions for external signer requests - * - * @param {Array<{type: string, kind?: number}>} permissions The permissions to request + * + * @param {Array} permissions The permissions to request * @returns {string} JSON string of permissions */ - static formatPermissions(permissions: Array<{type: string, kind?: number}>): string { + static formatPermissions(permissions: Array): string { return JSON.stringify(permissions); } - /** - * Create intent parameters for getting public key from external signer - * - * @param {Array<{type: string, kind?: number}>} permissions Default permissions to request - * @returns {Object} Parameters for the intent - */ - static createGetPublicKeyParams(permissions: Array<{type: string, kind?: number}> = []): any { - return { - type: 'get_public_key', - permissions: this.formatPermissions(permissions) - }; - } - - /** - * Create intent parameters for signing an event - * - * @param {Object} event The event to sign - * @param {string} currentUserPubkey The current user's public key - * @returns {Object} Parameters for the intent - */ - static createSignEventParams(event: any, currentUserPubkey: string): any { - return { - type: 'sign_event', - id: event.id || `event-${Date.now()}`, - current_user: currentUserPubkey - }; - } - /** * Check if we're running on Android - * + * * @returns {boolean} True if running on Android */ static isAndroid(): boolean {