The recipient input on /wallet's Send dialog now has a camera button
that opens a QR scanner. Bitcoin BIP-21 URIs are parsed and the
silent-payment fallback (?sp=) is preferred when present, falling
back to the on-chain address otherwise. Plain addresses, sp1… codes,
npub, and nprofile values are dropped into the input verbatim and
resolved by the existing recipient logic.
QrScannerDialog is a standalone component (ported from Ditto) that
owns the camera lifecycle via getUserMedia and the qr-scanner npm
package. It surfaces failure modes (insecure context, denied
permission, no camera, busy camera, overconstrained, ready timeout)
instead of a silent black screen, and offers a flash toggle when the
device supports it.
Android needed an explicit CAMERA permission in the manifest; iOS's
existing NSCameraUsageDescription string was extended to mention QR
scanning. No Capacitor camera plugin is required — the standard web
APIs work inside WKWebView and Android's WebView.
The iOS Associated Domains entitlement, Android intent filters, AASA
file, and assetlinks.json already reference agora.spot. Three call
sites still hard-coded ditto.pub:
- CREDENTIAL_DOMAIN in src/lib/credentialManager.ts, which keys iCloud
Keychain Shared Web Credentials by domain. Saved nsecs were being
filed under ditto.pub and so could never be matched against the
agora.spot AASA file.
- MainActivity.handleNotificationIntent host check, which only routed
the WebView when the tapped notification's URI host equaled
ditto.pub.
- NostrPoller.showNotification, which built notification PendingIntents
pointing at https://ditto.pub/notifications.
Renames the Capacitor app identifier from pub.agora.app to
spot.agora.app and cleans up Ditto-branded artifacts that don't refer
to upstream Ditto-the-project or Ditto-stack services.
App identifier (pub.agora.app -> spot.agora.app):
- capacitor.config.ts appId
- android applicationId, namespace, package_name string, custom_url_scheme
- iOS PRODUCT_BUNDLE_IDENTIFIER (Debug + Release)
- public/.well-known/assetlinks.json package_name
- public/.well-known/apple-app-site-association app id
- Info.plist BGTaskSchedulerPermittedIdentifiers and the matching
Swift bgTaskIdentifier (previously mismatched: plist said
pub.agora.app.notification-refresh, Swift said
pub.ditto.app.notification-refresh, so background refresh would
silently fail to register)
- src/lib/helpContent.ts Zapstore URLs
- .gitlab-ci.yml --package_name for fastlane supply
Android Java package (pub.ditto.app -> spot.agora.app):
- Move android/app/src/main/java/pub/ditto/app/ ->
android/app/src/main/java/spot/agora/app/ (4 files: MainActivity,
DittoNotificationPlugin, NostrPoller, NotificationRelayService)
- Update package declarations to match the new Android namespace
(was a hard build failure with namespace = spot.agora.app)
- Update proguard -keep rule
- Update NotificationRelayService ACTION_FETCH intent string
pub.ditto.app.ACTION_FETCH -> spot.agora.app.ACTION_FETCH
Fastlane (pub.ditto.app -> spot.agora.app):
- Appfile, Matchfile, Fastfile provisioning profile specifiers.
Matchfile still points at Soapbox's certificates git repo; a new
match repo with certs for spot.agora.app is required before iOS CI
signing works.
IPA artifact name (Ditto.ipa -> Agora.ipa):
- Fastfile output_name and matching CI artifact paths
- .gitlab-ci.yml: artifacts/Ditto.ipa references and the GitLab
Generic Packages path from /packages/generic/ditto/ ->
/packages/generic/agora/ (matches how APK/AAB are already
published). Existing release artifacts at the old path remain
reachable; new releases land at the new path.
Release-notes script fallback (Ditto vX.Y.Z -> Agora vX.Y.Z):
- scripts/extract-release-notes.mjs fallback used as the App Store /
Play Store 'What's New' blurb when a changelog section has no
summary.
manifest.webmanifest:
- Update related_applications Play Store entry to spot.agora.app.
- Remove the iTunes related_applications entry that pointed at
the existing Ditto App Store listing; not applicable to Agora
until Agora has its own listing.
Capacitor sync incidentals:
- npm run cap:sync picked up @capacitor/barcode-scanner registration
that had been missed in a prior plugin install
(android/app/capacitor.build.gradle, capacitor.settings.gradle,
ios/App/CapApp-SPM/Package.swift).
Intentionally NOT touched:
- ditto.json filename, DittoConfigSchema, DittoConfig, and JSDoc
references to ditto.json. The config-system shape is shared with
upstream Ditto by design.
- relay.ditto.pub, blossom.ditto.pub, ditto.pub/api/* and other
Ditto-stack services Agora actively consumes.
- The DittoNotificationPlugin Android/iOS class name, the
DittoNotification JS bridge name, ditto_notification_config
SharedPreferences keys, ic_stat_ditto drawables, and the
DittoBridgeViewController. Renaming requires a coordinated
JS-side rename plus a SharedPreferences migration or existing
users on the Ditto fork lose their notification config on upgrade.
- Ditto references in skill docs, NIP.md kind comments, README, and
zapstore.yaml attribution \u2014 those correctly describe the upstream
Ditto project that Agora forked from.
Follow-ups required before CI succeeds end-to-end (out of scope here):
- Stand up a new fastlane match git repo containing certs +
provisioning profiles for spot.agora.app, or update Matchfile
git_url to point at it.
- Register spot.agora.app in App Store Connect for team GZLTTH5DLM
and create a new App Store listing.
- Create a new Google Play Console listing for spot.agora.app
(package name is immutable per app on Play; the existing
pub.agora.app listing cannot be reused).
- Re-publish to Zapstore under spot.agora.app so the URLs in
helpContent.ts resolve.
When a logged-in user opens an nsite preview, a window.nostr provider is
injected into the sandboxed iframe. The provider proxies signEvent, nip04,
and nip44 calls to the parent signer over the existing JSON-RPC bridge.
A permission system gates each operation:
- getPublicKey is auto-allowed (clicking Run implies consent)
- signEvent prompts are granular per event kind (like Amber)
- encrypt/decrypt prompts are per operation type
- Users can check 'Remember for this site' to persist decisions
- Permissions are scoped to (userPubkey, siteId) in localStorage
The nsite preview nav bar gains a shield icon that opens a popover for
managing stored permissions.
Kind labels for the signer nudge, the permission prompt, and the post-
detail loading title now route through a central KIND_LABELS registry
(src/lib/kindLabels.ts) instead of three divergent inline maps.
The native SandboxPlugin (iOS WKWebView / Android WebView overlay) is
removed; SandboxFrame now always uses iframe.diy, so native behavior
matches web. This drops ~1100 lines of native code, the Android-only
blob prefetch workaround in NsitePreviewDialog, and the createPluginCall
registration in MainActivity and capacitor.config.json.
The sing action uses getUserMedia + MediaRecorder, which in a browser is
gated only by the standard web mic prompt. In Capacitor's Android
WebView it additionally requires the RECORD_AUDIO permission to be
declared in AndroidManifest.xml; without it the WebView rejects with
NotAllowedError and no system prompt is ever shown, so tapping record
silently fails on the Android app while working fine in the browser.
Also add MODIFY_AUDIO_SETTINGS, which some devices require for the
echoCancellation / noiseSuppression / autoGainControl constraints that
InlineSingCard passes to getUserMedia.
Separately, reorder AUDIO_MIME_CANDIDATES to prefer audio/mp4/aac over
audio/webm;codecs=opus. iOS WKWebView cannot decode WebM/Opus in an
<audio> element, so the recorded Blob's preview URL failed to load on
iOS. Android WebView and desktop Chromium both support mp4/aac, so
preferring it first is safe cross-platform. This mirrors the ordering
already used by useVoiceRecorder.ts.
- Sanitize event-sourced URLs before CSS url() interpolation in
ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
and Capacitor secure-storage SharedPreferences so wallet credentials
don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
to match what the CI job expects (closes M-2)
Android's shouldInterceptRequest blocks a pool of ~6 IO threads, each
waiting for JS to respond via the Capacitor bridge. With 200+ files
each requiring a network round-trip to Blossom, loading is painfully
slow. iOS doesn't have this problem — WKURLSchemeHandler is async.
Split the native plugin lifecycle into create() and navigate():
- create() adds the WebView container with spinner overlay (visible)
- navigate() loads the entry URL (triggers fetch interception)
On Android, onReady downloads all manifest blobs in parallel (12
concurrent fetches) into an in-memory cache while the native
ProgressBar spinner animates. Once navigate() fires, every resolveFile
call is an instant cache hit.
On iOS/web, onReady is a no-op and navigate() fires immediately.
iOS: load inline spinner HTML (centered spinning ring on dark background)
before navigating to the real content URL. Supports light/dark mode via
prefers-color-scheme. The spinner is replaced when the real page loads.
Android: use a native ProgressBar overlay instead of HTML — the HTML
spinner froze because constant Capacitor bridge calls saturated the
main thread, starving the WebView compositor. The native ProgressBar
animates on the render thread independently. Wrapped in a FrameLayout
with a dark overlay behind the spinner.
Both platforms: set WebView background to #14161f (app dark theme)
instead of white. Increased Android shouldInterceptRequest timeout
from 10s to 60s to prevent premature timeouts on large nsites.
Default to push mode (no foreground service). Persistent mode with
the always-on background polling service is opt-in via the new
Delivery Method section in notification settings.
- Add notificationStyle ('push' | 'persistent') to EncryptedSettings
- Show radio group in NotificationSettings on native platforms
- Pass notificationStyle through Capacitor plugin to SharedPreferences
- DittoNotificationPlugin starts/stops foreground service on style change
- MainActivity only starts service on launch when style is persistent
- Re-enable unread polling on native when push mode is active
Pass followed pubkeys through the Capacitor plugin to the native
polling service. When onlyFollowing is enabled, the relay query
includes an authors filter so only events from followed accounts
trigger native Android notifications.
Disabled notification types (e.g. reactions) still triggered push
notifications and showed the unread dot indicator, even though the
notification tab correctly filtered them out.
Three root causes fixed:
- useHasUnreadNotifications now uses getEnabledNotificationKinds to
only query for enabled types, preventing phantom unread dots
- NotificationSettings now syncs type preference changes to the
nostr-push server via updateSubscription (is_active toggle)
- Native Android poller now receives enabled kinds from the JS layer
and uses them in the relay filter instead of hardcoded kinds
Letters were completely absent from the notification pipeline — users had
to visit the Letters page to discover incoming letters. This integrates
kind 8211 into every layer of the notification system:
- useNotifications: query, grouping, and referenced-event exclusion
- useHasUnreadNotifications: unread dot indicator
- NotificationsPage: LetterNotification component with link to /letters
- NotificationSettings: toggleable Letters row
- notificationTemplates: web push template
- Android NotificationRelayService + NostrPoller: native push support
- EncryptedSettings + schema: letters preference field
Closes#188
When onCreate() catches ForegroundServiceStartNotAllowedException and calls
stopSelf() early, httpClient is never initialized. onDestroy() still runs and
was crashing with a NullPointerException on httpClient.dispatcher().
Android 16 (targetSdk 36) enforces strict time limits on dataSync
foreground services. When the limit is exhausted, calling startForeground()
or startForegroundService() throws ForegroundServiceStartNotAllowedException
and kills the app. Catch the exception in both NotificationRelayService.onCreate()
and MainActivity.onCreate() so the app continues running; the AlarmManager
will reschedule the next fetch cycle at the normal interval.
- Add Android intent-filter with autoVerify for https://ditto.pub/*
- Create DeepLinkHandler component to handle appUrlOpen events at runtime
- Mount DeepLinkHandler in AppRouter inside BrowserRouter context
- Add public/.well-known/assetlinks.json with pub.ditto.app package and signing cert fingerprint
- Add kind 1111 to the Nostr REQ filter in NotificationRelayService
- Add case 1111 to kindToAction() in NostrPoller with the same context-aware
label logic: 'replied to your comment' when k=1111, 'commented on your post' otherwise
The icon generation script was only producing ic_launcher_foreground.png
(adaptive icon layer) and leaving ic_launcher.png/ic_launcher_round.png as
solid purple squares with no logo. This caused the app icon to appear as a
big purple circle on device. The script now generates all three icon types
at all density buckets.
Reverts broken changes from intermediate commits that:
- Changed ic_launcher_foreground.xml viewport from 100x100 to 90x80 with scale
- Changed mipmap-anydpi-v26 to reference @drawable instead of @mipmap foreground
- Changed ic_stat_ditto.xml viewport from 100x100 to 90x80
- Rewrote generate-icons.sh to generate legacy icons with broken viewBox hack
- Produced tiny broken foreground PNGs (318-340 bytes)
All icon resources and the script are now identical to 4abe629, with freshly
regenerated foreground PNGs at the correct 66% content size.
- Regenerate all ic_launcher*.png and ic_launcher_foreground.png which had regressed
to tiny broken files (318-343 bytes) that rendered as white squares on purple
- Fix ic_stat_ditto.xml: change viewportWidth/Height from 100x100 to 90x80 to match
the effective logo bounds, correcting the off-center logo in the notification tray
The logo was still off-center because the SVG viewBox '-5 -10 100 100'
is asymmetric and doesn't properly frame the logo content.
Changes:
- Update generate-icons.sh: Change viewBox from '-5 -10 100 100' to '0 0 90 80'
- Update vector foreground: Change viewportWidth/Height from 100x100 to 90x80
- Adjust pivot point to match new viewport dimensions
- Regenerate all launcher PNGs and adaptive foreground PNGs with corrected viewBox
The logo should now be properly centered without the left gap.
The logo was off-center with a gap on the left due to the SVG's asymmetric
viewBox (-5, -10, 100, 100). Fixed by adjusting rendering and positioning.
Changes:
- Update generate-icons.sh: Render at 600px and crop to 512px for better centering
- Update vector foreground: Shift translateX from 5 to 7 to compensate for viewBox offset
- Regenerate all launcher PNGs and adaptive foreground PNGs
The logo now appears properly centered in the icon.
Zoomed out the logo further from 50% to 40% for better visual balance
and more padding around the logo.
Changes:
- Update generate-icons.sh: Change content_size from 50% to 40%
- Update vector foreground: Scale from 0.75 to 0.6 to match PNG sizing
- Regenerate all launcher PNGs and adaptive foreground PNGs
The logo now has generous padding and looks more refined in the app launcher.
The logo was zoomed in too far, making it difficult to see the full design.
Reduced the logo size to 50% of the icon canvas for proper padding.
Changes:
- Update generate-icons.sh: Change content_size from 66% to 50%
- Update vector foreground: Add 0.75 scale transformation to match PNG sizing
- Regenerate all launcher PNGs with the smaller, better-proportioned logo
- Regenerate all adaptive foreground PNGs
The logo now has proper breathing room and looks much better in the app launcher.
The APK was showing just a purple circle because the legacy launcher PNG files
(ic_launcher.png and ic_launcher_round.png) were solid purple with no logo.
Changes:
- Update generate-icons.sh to generate legacy launcher PNGs (for Android < 8.0)
- Add ImageMagick as fallback SVG renderer when inkscape/rsvg unavailable
- Replace 'bc' calculations with bash arithmetic for better compatibility
- Generate both square and round launcher icons with white logo on purple bg
- Regenerate all launcher icons (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
The icons now properly display:
- Purple background (#7c52e0)
- White Ditto logo (66% safe zone sizing)
- Round variant with circular mask
This fixes the issue for both:
- Modern devices (Android 8.0+): Uses adaptive icon system with vector foreground
- Legacy devices (Android < 8.0): Uses pre-rendered PNG launcher icons
- Delete conflicting ic_launcher_background.xml drawable with teal grid pattern
- Update adaptive icon XMLs to use vector drawable instead of PNG mipmap
- Change from @mipmap/ic_launcher_foreground to @drawable/ic_launcher_foreground
- Vector drawable (drawable-v24/ic_launcher_foreground.xml) contains proper white Ditto logo
- Background color (#7c52e0 purple) is correctly defined in values/ic_launcher_background.xml
The APK was showing only a purple circle because the vector foreground logo wasn't being used. Now the adaptive icon system will properly composite the white Ditto logo on the purple background.
- Update strings.xml to use 'Ditto' instead of 'ditto'
- Update capacitor.config.ts appName to 'Ditto'
- Update GitLab CI/CD artifact naming to use 'Ditto-' prefix
- Ensures consistency with web version branding
- Extract zap amount from kind 9735 events in NostrPoller.java (checks
amount tag then falls back to description tag's zap request)
- Format amounts compactly (e.g. 1.5K sats, 2M sats)
- Set data URI on notification PendingIntent so tapping opens /notifications
- Handle deep link intent in MainActivity (both onCreate and onNewIntent)
- Remove dead localNotificationActionPerformed listener from TypeScript
(only fired for Capacitor-API notifications, not natively posted ones)
The notification service was holding a PARTIAL_WAKE_LOCK indefinitely,
preventing the CPU from ever sleeping. Combined with OkHttp's 30-second
ping interval, this caused continuous CPU wake-ups — worse than the
previous polling approach.
Changes:
- Remove permanent WakeLock that kept CPU running 24/7
- Disable OkHttp's 30s ping interval (useless without WakeLock in Doze)
- Add AlarmManager keepalive using setAndAllowWhileIdle() every ~8 min
- Acquire brief WakeLock (15s timeout) only during keepalive checks
- Track connection liveness via relay message timestamps
- Reconnect automatically when Doze kills the TCP connection
- Missed events are recovered via the existing 'since' timestamp
Expected improvement: ~3 brief wake-ups/hour vs continuous CPU usage.
Notifications may be delayed up to ~8 min in deep sleep.
Android 12+ crashes the app if startForeground() isn't called within 5
seconds of startForegroundService(). Moved it from onStartCommand() to
the top of onCreate() to ensure the foreground notification is posted
before any other initialization.
Replaces the AlarmManager-based polling (wake every 60s, open/close WebSocket
connections) with a persistent WebSocket subscription to a Nostr relay. Events
are delivered in real-time via the open connection. A partial WakeLock keeps
the connection alive through Doze mode (same approach as Signal without FCM).
- Add NotificationRelayService: foreground service with persistent WebSocket,
exponential backoff reconnection, network-aware reconnect, relay rotation
on failure, and SharedPreferences config listener
- Refactor NostrPoller into notification dispatcher with metadata caching
(SharedPreferences-based, avoids redundant WebSocket connections for
repeat authors)
- Remove NotificationService (AlarmManager scheduler) and PollAlarmReceiver
(WakeLock-based polling)
- Remove SCHEDULE_EXACT_ALARM permission (no longer needed)
- Disable web-layer background refetch on native (persistent connection
handles it)