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
This commit is contained in:
DocNR 2025-04-01 00:03:41 -04:00
parent aad1b875a3
commit 8e68fcf60f
12 changed files with 795 additions and 209 deletions

5
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@ -10,11 +12,21 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
<!-- Add query for nostrsigner scheme used by Amber -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="nostrsigner"/>
</intent>
<!-- Add specific package query for Amber -->
<package android:name="com.greenart7c3.nostrsigner"/>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://u.expo.dev/f3895f49-d9c9-4653-b73b-356f727debe2"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@ -26,6 +38,13 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="myapp"/>
<data android:scheme="com.powr.app"/>
<data android:scheme="exp+powr"/>
</intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="powr"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
</application>

View File

@ -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
}
}

View File

@ -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<NativeModule> {
return listOf(AmberSignerModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
return emptyList()
}
}

View File

@ -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<ReactPackage> {
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)

View File

@ -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';
@ -83,29 +82,50 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
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);
// 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)
];
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.");
// 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.');
}
}
};
@ -128,26 +148,27 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
<View className="space-y-4">
{/* External signer option (Android only) */}
{Platform.OS === 'android' && (
<Button
onPress={handleAmberLogin}
disabled={isLoading}
className="mb-3 py-3"
variant="outline"
style={{ borderColor: 'hsl(261, 90%, 66%)' }}
>
{isLoading ? (
<ActivityIndicator size="small" color="hsl(261, 90%, 66%)" />
) : (
<View className="flex-row items-center">
<ExternalLink size={18} className="mr-2" color="hsl(261, 90%, 66%)" />
<Text className="font-medium" style={{ color: 'hsl(261, 90%, 66%)' }}>Sign with Amber</Text>
</View>
)}
</Button>
<>
<Button
onPress={handleAmberLogin}
disabled={isLoading}
className="mb-3 py-3"
variant="outline"
style={{ borderColor: 'hsl(261 90% 66%)' }}
>
{isLoading ? (
<ActivityIndicator size="small" color="hsl(261 90% 66%)" />
) : (
<View className="flex-row items-center">
<ExternalLink size={18} className="mr-2" color="hsl(261 90% 66%)" />
<Text className="font-medium" style={{ color: 'hsl(261 90% 66%)' }}>Sign with Amber</Text>
</View>
)}
</Button>
<Text className="text-sm text-muted-foreground mb-3">- or -</Text>
</>
)}
<Text className="text-sm text-muted-foreground mb-3">- or -</Text>
<Text className="text-base">Enter your Nostr private key (nsec)</Text>
<Input
placeholder="nsec1..."
@ -179,7 +200,7 @@ export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps)
onPress={handleLogin}
disabled={isLoading}
className="flex-1 py-3"
style={{ backgroundColor: 'hsl(261, 90%, 66%)' }}
style={{ backgroundColor: 'hsl(261 90% 66%)' }}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />

View File

@ -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.

View File

@ -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';

View File

@ -1,8 +1,8 @@
// 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)
@ -10,7 +10,7 @@ import ExternalSignerUtils from '@/utils/ExternalSignerUtils';
* 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 {
/**
@ -21,7 +21,7 @@ export class NDKAmberSigner implements NDKSigner {
/**
* The package name of the Amber app
*/
private packageName: string | null = 'com.greenart7c3.nostrsigner';
private packageName: string;
/**
* Whether this signer can sign events
@ -34,7 +34,7 @@ export class NDKAmberSigner implements NDKSigner {
* @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';
@ -75,8 +75,7 @@ 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
@ -87,19 +86,23 @@ 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
try {
// Get the npub representation of the hex pubkey
const npub = nip19.npubEncode(this.pubkey);
console.log('Amber signing event:', event);
// Use ExternalSignerUtils to sign the event
const response = await ExternalSignerUtils.signEvent(event, npub);
// 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
if (!response.signature) {
throw new Error('No signature returned from Amber');
}
throw new Error('NDKAmberSigner.sign() not implemented');
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}`);
}
}
/**
@ -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.
* Uses the ExternalSignerUtils to communicate with Amber.
*
* @returns Promise with public key and package name
* @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
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);
// 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');
// 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');
}
}
// When implemented, this would return:
// return { pubkey: 'hex_pubkey_from_amber', packageName: 'com.greenart7c3.nostrsigner' };
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}`);
}
}
}

View File

@ -253,40 +253,88 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
// Use NDK to publish
try {
// Create a new event
const event = new NDKEvent(ndk as any);
try {
console.log('Starting workout event publish...');
// Set the properties
event.kind = eventData.kind;
event.content = eventData.content;
event.tags = eventData.tags || [];
event.created_at = eventData.created_at;
// Create a new event
const event = new NDKEvent(ndk as any);
// Sign and publish
await event.sign();
await event.publish();
// Set the properties
event.kind = eventData.kind;
event.content = eventData.content;
event.tags = eventData.tags || [];
event.created_at = eventData.created_at;
console.log('Successfully published workout event');
// Add timeout for signing to prevent hanging
const signPromise = event.sign();
const signTimeout = new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error('Signing timeout after 15 seconds')), 15000);
});
// Handle social share if selected
if (options.shareOnSocial && options.socialMessage) {
const socialEventData = NostrWorkoutService.createSocialShareEvent(
event.id,
options.socialMessage
);
try {
// Race the sign operation against a timeout
await Promise.race([signPromise, signTimeout]);
console.log('Event signed successfully');
// 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;
// Add timeout for publishing as well
const publishPromise = event.publish();
const publishTimeout = new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error('Publishing timeout after 15 seconds')), 15000);
});
// Sign and publish
await socialEvent.sign();
await socialEvent.publish();
await Promise.race([publishPromise, publishTimeout]);
console.log('Successfully published workout event');
console.log('Successfully published social share');
// 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<void>((_, 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<void>((_, 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);

View File

@ -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)
@ -9,71 +27,117 @@ 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<boolean>} True if an external signer is available
*/
static isExternalSignerInstalled(): Promise<boolean> {
// This would need to be implemented with native modules
// For now, we'll return false if not on Android
static async isExternalSignerInstalled(): Promise<boolean> {
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<NIP55Permission>} permissions Default permissions to request
* @returns {Promise<{pubkey: string, packageName: string}>} Public key and package name of the signer
*/
static async requestPublicKey(
permissions: Array<NIP55Permission> = []
): 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<AmberResponse>} The response from Amber including signature
*/
static async signEvent(
event: any,
currentUserPubkey: string,
eventId?: string
): Promise<AmberResponse> {
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<NIP55Permission>} permissions The permissions to request
* @returns {string} JSON string of permissions
*/
static formatPermissions(permissions: Array<{type: string, kind?: number}>): string {
static formatPermissions(permissions: Array<NIP55Permission>): 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
*