From e461bc9beba88a33e5d92973c8f6c627d231079a Mon Sep 17 00:00:00 2001 From: krzyczak Date: Sun, 1 Mar 2026 14:48:46 +0000 Subject: [PATCH 1/2] feat: implement private sync over Nostr protocol --- NOSTR_SYNC.md | 227 ++ app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 11 + .../java/org/schabi/newpipe/MainActivity.java | 10 + .../newpipe/database/feed/dao/FeedGroupDAO.kt | 6 + .../database/history/dao/StreamHistoryDAO.kt | 3 + .../database/stream/dao/StreamStateDAO.kt | 9 + .../database/subscription/SubscriptionDAO.kt | 3 + .../local/history/HistoryRecordManager.java | 53 +- .../history/StatisticsPlaylistFragment.java | 69 +- .../local/nostr/Nip55SignerClient.java | 206 ++ .../newpipe/local/nostr/NostrKeyUtils.java | 761 +++++++ .../local/nostr/NostrSyncFragment.java | 1045 +++++++++ .../newpipe/local/nostr/NostrSyncManager.java | 1867 +++++++++++++++++ .../local/nostr/PortraitCaptureActivity.java | 6 + .../subscription/SubscriptionFragment.kt | 36 + .../local/subscription/SubscriptionManager.kt | 9 +- .../schabi/newpipe/util/NavigationHelper.java | 8 + app/src/main/res/drawable/ic_content_copy.xml | 10 + .../main/res/drawable/ic_qr_code_scanner.xml | 10 + app/src/main/res/drawable/ic_sync.xml | 10 + .../main/res/drawable/ic_visibility_off.xml | 16 + .../layout/dialog_nostr_create_account.xml | 41 + .../main/res/layout/dialog_nostr_identity.xml | 119 ++ .../main/res/layout/dialog_nostr_sign_in.xml | 183 ++ .../res/layout/fragment_history_playlist.xml | 76 + .../main/res/layout/fragment_nostr_sync.xml | 104 + .../main/res/layout/fragment_subscription.xml | 19 +- app/src/main/res/values/strings.xml | 67 + .../android/en-US/changelogs/1010.txt | 14 + gradle/libs.versions.toml | 4 + 31 files changed, 4988 insertions(+), 16 deletions(-) create mode 100644 NOSTR_SYNC.md create mode 100644 app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/nostr/PortraitCaptureActivity.java create mode 100644 app/src/main/res/drawable/ic_content_copy.xml create mode 100644 app/src/main/res/drawable/ic_qr_code_scanner.xml create mode 100644 app/src/main/res/drawable/ic_sync.xml create mode 100644 app/src/main/res/drawable/ic_visibility_off.xml create mode 100644 app/src/main/res/layout/dialog_nostr_create_account.xml create mode 100644 app/src/main/res/layout/dialog_nostr_identity.xml create mode 100644 app/src/main/res/layout/dialog_nostr_sign_in.xml create mode 100644 app/src/main/res/layout/fragment_history_playlist.xml create mode 100644 app/src/main/res/layout/fragment_nostr_sync.xml create mode 100644 fastlane/metadata/android/en-US/changelogs/1010.txt diff --git a/NOSTR_SYNC.md b/NOSTR_SYNC.md new file mode 100644 index 00000000000..252afb0d0d4 --- /dev/null +++ b/NOSTR_SYNC.md @@ -0,0 +1,227 @@ +# Nostr Sync + +## What This PR Adds + +This PR adds a new **Nostr Sync** feature area and end-to-end private sync for: + +1. Watch history (including per-video resume position) +2. Subscriptions (including subscription group membership) + +The sync transport is Nostr app data events: + +1. **Kind**: `30078` +2. **Storage convention**: `NIP-78` +3. **Payload encryption**: `NIP-44` +4. **External signer integration**: `NIP-55` (Amber-compatible) + +## User-Facing Changes + +### Navigation and screen + +1. Added a new **Nostr Sync** section in the left panel. +2. Nostr Sync screen contains: + - `Sync watch history` toggle + - `Sync subscriptions` toggle + - `Sign In` (when no identity) + - `Show Identity` + `Clear Identity` (when identity exists) + - Relay checklist with: + - add relay + - remove relay + - reset relays to defaults + +### Identity flows + +When identity is not set: + +1. `Sign In` opens dialog. +2. If NIP-55 signer app exists: + - Prompt to use signer app. +3. If signer app is missing: + - Prompt to install Amber. +4. `Advanced` section supports: + - Generate local keypair + - Scan `nsec` from QR + - Paste `nsec` in input field and import via dialog `Done` + +When identity is set: + +1. `Show Identity` dialog shows: + - `npub` + copy + QR + - `nsec` (if local) masked by default + eye toggle + copy + QR + - profile image if available +2. For signer-managed identities (no local `nsec`): + - `nsec` label remains visible + - explanatory message shown under label +3. `Clear Identity` shows destructive confirmation. + +### Pull-to-sync + +1. Added pull-to-refresh-triggered sync in: + - History view + - Subscriptions view +2. Refresh indicator remains visible while sync is running and stops when it completes/fails. + +## Relay Configuration + +Configured relay list: + +- `wss://relay.primal.net` +- `wss://relay.damus.io` +- `wss://relay.snort.social` +- `wss://nostr.oxtr.dev` +- `wss://nos.lol` +- `wss://nostr.bitcoiner.social` +- `wss://nostr.semisol.dev` +- `wss://shu01.shugur.net` +- `wss://shu02.shugur.net` +- `wss://shu03.shugur.net` +- `wss://shu04.shugur.net` +- `wss://shu05.shugur.net` + +Default enabled relays for fresh config: + +1. `wss://relay.primal.net` +2. `wss://relay.damus.io` +3. `wss://relay.snort.social` +4. `wss://nostr.oxtr.dev` +5. `wss://nos.lol` +6. `wss://nostr.bitcoiner.social` +7. `wss://nostr.semisol.dev` + +All `shu*.shugur.net` relays are unchecked by default. + +Relay list is user-configurable in-app: + +1. Add custom relay URL +2. Remove any relay entry +3. Reset list and checked state back to defaults + +## Sync Protocol and Event Shape + +### Event kind and filtering + +1. Sync reads/writes Nostr events with kind `30078`. +2. Relay query filter includes: + - `kinds: [30078]` + - author pubkey + +### Payload envelope + +Published event content wraps encrypted payload in JSON: + +- `data.enc = "nip44"` +- `data.ciphertext = ` + +The encrypted plaintext payload includes: + +1. `v` (payload version) +2. `category` (`watch_history` or `subscriptions`) +3. `updated_at` +4. `data` object (actual merged records) + +Event tags include: + +1. `d` tag (device/category-scoped) +2. `p` tag (self pubkey) +3. `client` tag (`newpipe-sync`) + +### Sign/encrypt modes + +Two modes are supported: + +1. **Local key mode** (`nsec` present): + - NIP-44 encrypt/decrypt locally + - event signing locally +2. **Signer-only mode** (no local `nsec`, NIP-55 identity): + - NIP-44 encrypt/decrypt via signer app + - event signing via signer app + +## Merge Strategy (CRDT-style) + +This implementation uses deterministic per-record merge rules (CRDT-style behavior), so data from multiple devices converges. + +### Watch history merge + +Keyed by `serviceId + url`. + +For each record: + +1. `repeatCount = max(local, remote)` +2. Newer `accessTs` wins for time-sensitive fields (title/type/duration/uploader/thumbnail) +3. Missing fields are filled from the other side when possible +4. `progressMillis` (resume position) follows newer `accessTs`; if winner has no value, + keep the available non-negative value from the other side; on equal `accessTs`, max progress wins + +DB apply rule: + +1. Upsert stream row +2. Compare with latest history entry +3. Store merged `accessDate = max(local, remote)` and merged `repeatCount = max(local, remote)` +4. Upsert `stream_state.progress_time` when merged record includes `progressMillis` + +### Subscription merge + +Keyed by `serviceId + url`. + +For each record: + +1. Text fields kept if non-empty (prefer existing non-empty, fill blanks) +2. `subscriberCount` uses max positive value when available +3. Group memberships merged as union by group name +4. Group icon conflict rule prefers non-default icon over default `ALL` + +DB apply rule: + +1. Insert missing subscriptions +2. Update existing only if merged values changed +3. Reconcile feed groups by name (create/update) +4. Rewrite membership for each group from merged membership set + +## Robustness and UX Fixes Included + +1. Crash fixes around Nostr Sync screen initialization. +2. Icon updates (`sync`, QR icon variant). +3. Portrait-locked QR scanning capture activity. +4. NIP-55 handling fixes for signer result parsing and package resolution. +5. Improved identity dialog layout/visibility behavior across screen sizes. + +## Known Scope Boundaries + +1. Watch later playlist sync is intentionally not implemented in the active flow. +2. Public playlist publication is not implemented in this PR. + +## Testing Notes + +Manual flows covered during development: + +1. Local `nsec` identity generation/import/clear/show +2. Signer-managed identity via NIP-55 +3. History sync across devices with same identity and relays +4. Resume-position sync (`stream_state.progress_time`) across devices +5. Subscription sync including grouped subscriptions +6. Pull-to-sync behavior in History and Subscriptions +7. Relay enable/disable behavior + +## Files of Interest + +Core: + +1. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java` +2. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java` +3. `app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java` +4. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java` + +DAO: + +1. `app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt` +2. `app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt` +3. `app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt` +4. `app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt` + +UI: + +1. `app/src/main/res/layout/fragment_nostr_sync.xml` +2. `app/src/main/res/layout/dialog_nostr_sign_in.xml` +3. `app/src/main/res/layout/dialog_nostr_identity.xml` +4. `app/src/main/res/layout/fragment_history_playlist.xml` +5. `app/src/main/res/layout/fragment_subscription.xml` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9215a4a1be..9e31d41aadb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -293,7 +293,9 @@ dependencies { /** Third-party libraries **/ // Instance state boilerplate elimination + implementation(libs.bouncycastle.bcprov) implementation(libs.livefront.bridge) + implementation(libs.journeyapps.zxing.android.embedded) implementation(libs.evernote.statesaver.core) kapt(libs.evernote.statesaver.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e4f7522116..b2a3f82c29e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,12 @@ + + + + + + + + > + @Query("SELECT * FROM feed_group ORDER BY sort_order ASC") + abstract fun getAllBlocking(): List + @Query("SELECT * FROM feed_group WHERE uid = :groupId") abstract fun getGroup(groupId: Long): Maybe @@ -39,6 +42,9 @@ abstract class FeedGroupDAO { @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> + @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun getSubscriptionIdsForBlocking(groupId: Long): List + @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt index 916d4e5ed21..c2a3c340e49 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt @@ -34,6 +34,9 @@ abstract class StreamHistoryDAO : BasicDAO { @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC") abstract val historySortedById: Flowable> + @Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC") + abstract fun getHistorySortedByIdBlocking(): List + @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1") abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity? diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt index 62be08d845e..656b3a36eae 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt @@ -22,6 +22,9 @@ interface StreamStateDAO : BasicDAO { @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE) override fun getAll(): Flowable> + @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE) + fun getAllBlocking(): List + @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE) override fun deleteAll(): Int @@ -32,6 +35,12 @@ interface StreamStateDAO : BasicDAO { @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") fun getState(streamId: Long): Maybe + @Query( + "SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId LIMIT 1" + ) + fun getStateBlocking(streamId: Long): StreamStateEntity? + @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") fun deleteState(streamId: Long): Int diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt index 353c7148e32..84434783e82 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -21,6 +21,9 @@ abstract class SubscriptionDAO : BasicDAO { @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") abstract override fun getAll(): Flowable> + @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") + abstract fun getAllBlocking(): List + @Query( """ SELECT * FROM subscriptions diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index f2fdf9eba63..1faccf81e55 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -25,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.collection.LongLongPair; +import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import org.schabi.newpipe.NewPipeDatabase; @@ -47,6 +48,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.local.feed.FeedViewModel; +import org.schabi.newpipe.local.nostr.NostrSyncManager; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import java.time.OffsetDateTime; @@ -61,6 +63,9 @@ import io.reactivex.rxjava3.schedulers.Schedulers; public class HistoryRecordManager { + private static final long NOSTR_PROGRESS_SYNC_DELTA_MILLIS = 15_000L; + + private final Context appContext; private final AppDatabase database; private final StreamDAO streamTable; private final StreamHistoryDAO streamHistoryTable; @@ -71,6 +76,7 @@ public class HistoryRecordManager { private final String streamHistoryKey; public HistoryRecordManager(final Context context) { + appContext = context.getApplicationContext(); database = NewPipeDatabase.getInstance(context); streamTable = database.streamDAO(); streamHistoryTable = database.streamHistoryDAO(); @@ -125,6 +131,7 @@ public Completable markAsWatched(final StreamInfoItem info) { streamHistoryTable.insert(entry); } })) + .doOnComplete(() -> NostrSyncManager.requestSync(appContext)) .subscribeOn(Schedulers.io()); } @@ -147,7 +154,8 @@ public Maybe onViewed(final StreamInfo info) { // just viewed for the first time: set 1 view return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); } - })).subscribeOn(Schedulers.io()); + })).doOnSuccess(id -> NostrSyncManager.requestSync(appContext)) + .subscribeOn(Schedulers.io()); } public Completable deleteStreamHistoryAndState(final long streamId) { @@ -242,13 +250,28 @@ public Maybe loadStreamState(final StreamInfo info) { } public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { - return Completable.fromAction(() -> database.runInTransaction(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - final var state = new StreamStateEntity(streamId, progressMillis); - if (state.isValid(info.getDuration())) { + return Completable.fromAction(() -> { + final boolean[] shouldRequestSync = {false}; + database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + final var state = new StreamStateEntity(streamId, progressMillis); + if (!state.isValid(info.getDuration())) { + return; + } + + final var previousState = streamStateTable.getStateBlocking(streamId); streamStateTable.upsert(state); + shouldRequestSync[0] = shouldSyncProgressState( + previousState, + state, + info.getDuration() + ); + }); + + if (shouldRequestSync[0]) { + NostrSyncManager.requestSync(appContext); } - })).subscribeOn(Schedulers.io()); + }).subscribeOn(Schedulers.io()); } public Maybe loadStreamState(final InfoItem info) { @@ -287,4 +310,22 @@ public Single removeOrphanedRecords() { return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); } + private static boolean shouldSyncProgressState(@Nullable final StreamStateEntity previousState, + @NonNull final StreamStateEntity state, + final long durationInSeconds) { + if (previousState == null) { + return true; + } + + if (Math.abs(state.getProgressMillis() - previousState.getProgressMillis()) + >= NOSTR_PROGRESS_SYNC_DELTA_MILLIS) { + return true; + } + + if (durationInSeconds <= 0) { + return false; + } + return previousState.isFinished(durationInSeconds) != state.isFinished(durationInSeconds); + } + } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 43b7f1c0db2..eea18cf9db2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -13,6 +13,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.evernote.android.state.State; import com.google.android.material.snackbar.Snackbar; @@ -34,6 +35,7 @@ import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.local.nostr.NostrSyncManager; import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -53,6 +55,8 @@ public class StatisticsPlaylistFragment extends BaseLocalListFragment, Void> implements PlaylistControlViewHolder { + private static final long PULL_REFRESH_POLL_DELAY_MS = 250L; + private final CompositeDisposable disposables = new CompositeDisposable(); @State Parcelable itemsListState; @@ -60,10 +64,13 @@ public class StatisticsPlaylistFragment private StatisticPlaylistControlBinding headerBinding; private PlaylistControlBinding playlistControlBinding; + @Nullable + private SwipeRefreshLayout swipeRefreshLayout; /* Used for independent events */ private Subscription databaseSubscription; private HistoryRecordManager recordManager; + private final Runnable syncPollRunnable = this::pollSyncCompletion; private List processResult(final List results) { final Comparator comparator; @@ -95,7 +102,7 @@ public void onCreate(final Bundle savedInstanceState) { public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); + return inflater.inflate(R.layout.fragment_history_playlist, container, false); } @Override @@ -103,6 +110,9 @@ public void onResume() { super.onResume(); if (activity != null) { setTitle(activity.getString(R.string.title_activity_history)); + if (activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setSubtitle(null); + } } } @@ -111,6 +121,9 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_history, menu); + if (activity != null && activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setSubtitle(null); + } } /////////////////////////////////////////////////////////////////////////// @@ -120,6 +133,7 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); + swipeRefreshLayout = rootView.findViewById(R.id.history_swipe_refresh_layout); if (!useAsFrontPage) { setTitle(getString(R.string.title_last_played)); } @@ -137,6 +151,9 @@ protected Supplier getListHeaderSupplier() { @Override protected void initListeners() { super.initListeners(); + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setOnRefreshListener(this::triggerNostrPullRefresh); + } itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override @@ -193,6 +210,13 @@ public void onPause() { @Override public void onDestroyView() { + if (itemsList != null) { + itemsList.removeCallbacks(syncPollRunnable); + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setOnRefreshListener(null); + swipeRefreshLayout.setRefreshing(false); + } super.onDestroyView(); if (itemListAdapter != null) { @@ -201,6 +225,7 @@ public void onDestroyView() { headerBinding = null; playlistControlBinding = null; + swipeRefreshLayout = null; if (databaseSubscription != null) { databaseSubscription.cancel(); @@ -223,7 +248,9 @@ private Subscriber> getHistoryObserver() { return new Subscriber>() { @Override public void onSubscribe(final Subscription s) { - showLoading(); + if (swipeRefreshLayout == null || !swipeRefreshLayout.isRefreshing()) { + showLoading(); + } if (databaseSubscription != null) { databaseSubscription.cancel(); @@ -235,6 +262,7 @@ public void onSubscribe(final Subscription s) { @Override public void onNext(final List streams) { handleResult(streams); + stopPullRefresh(); if (databaseSubscription != null) { databaseSubscription.request(1); } @@ -242,6 +270,7 @@ public void onNext(final List streams) { @Override public void onError(final Throwable exception) { + stopPullRefresh(); showError( new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics")); } @@ -313,6 +342,41 @@ private void toggleSortMode() { startLoading(true); } + private void triggerNostrPullRefresh() { + NostrSyncManager.requestSync(requireContext()); + if (itemsList != null) { + itemsList.removeCallbacks(syncPollRunnable); + itemsList.postDelayed(syncPollRunnable, PULL_REFRESH_POLL_DELAY_MS); + } else { + stopPullRefresh(); + } + } + + private void pollSyncCompletion() { + if (swipeRefreshLayout == null) { + return; + } + if (!NostrSyncManager.isSyncRunning()) { + stopPullRefresh(); + return; + } + if (itemsList != null) { + itemsList.postDelayed(syncPollRunnable, PULL_REFRESH_POLL_DELAY_MS); + } + } + + private void stopPullRefresh() { + if (swipeRefreshLayout == null) { + return; + } + if (itemsList != null) { + itemsList.removeCallbacks(syncPollRunnable); + } + if (swipeRefreshLayout.isRefreshing()) { + swipeRefreshLayout.setRefreshing(false); + } + } + private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); } @@ -389,4 +453,3 @@ private enum StatisticSortMode { MOST_PLAYED, } } - diff --git a/app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java b/app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java new file mode 100644 index 00000000000..33e860f4978 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java @@ -0,0 +1,206 @@ +package org.schabi.newpipe.local.nostr; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +final class Nip55SignerClient { + private static final String TAG = "Nip55SignerClient"; + private static final String OP_SIGN_EVENT = "SIGN_EVENT"; + private static final String OP_NIP44_ENCRYPT = "NIP44_ENCRYPT"; + private static final String OP_NIP44_DECRYPT = "NIP44_DECRYPT"; + + private Nip55SignerClient() { + } + + @Nullable + static String nip44Encrypt(@NonNull final Context context, + @NonNull final String signerPackage, + @NonNull final String plainText, + @NonNull final String recipientPubKeyHex, + @NonNull final String currentUserPubKeyHex) { + final QueryResult queryResult = querySigner(context, signerPackage, OP_NIP44_ENCRYPT, + new String[]{plainText, recipientPubKeyHex, currentUserPubKeyHex}); + if (queryResult == null) { + return null; + } + if (queryResult.rejected) { + Log.w(TAG, "Signer rejected NIP-44 encrypt: " + queryResult.errorMessage); + return null; + } + return queryResult.result; + } + + @Nullable + static String nip44Decrypt(@NonNull final Context context, + @NonNull final String signerPackage, + @NonNull final String cipherText, + @NonNull final String senderPubKeyHex, + @NonNull final String currentUserPubKeyHex) { + final QueryResult queryResult = querySigner(context, signerPackage, OP_NIP44_DECRYPT, + new String[]{cipherText, senderPubKeyHex, currentUserPubKeyHex}); + if (queryResult == null) { + return null; + } + if (queryResult.rejected) { + Log.w(TAG, "Signer rejected NIP-44 decrypt: " + queryResult.errorMessage); + return null; + } + return queryResult.result; + } + + @Nullable + static JSONObject signEvent(@NonNull final Context context, + @NonNull final String signerPackage, + @NonNull final JSONObject unsignedEvent, + @NonNull final String currentUserPubKeyHex, + @NonNull final String fallbackEventId) { + final QueryResult queryResult = querySigner(context, signerPackage, OP_SIGN_EVENT, + new String[]{unsignedEvent.toString(), "", currentUserPubKeyHex}); + if (queryResult == null) { + return null; + } + if (queryResult.rejected) { + Log.w(TAG, "Signer rejected SIGN_EVENT: " + queryResult.errorMessage); + return null; + } + + try { + JSONObject signedEvent = null; + if (!TextUtils.isEmpty(queryResult.eventJson)) { + signedEvent = new JSONObject(queryResult.eventJson); + } else if (!TextUtils.isEmpty(queryResult.result)) { + final String result = queryResult.result.trim(); + if (result.startsWith("{")) { + signedEvent = new JSONObject(result); + } else { + signedEvent = new JSONObject(unsignedEvent.toString()) + .put("sig", result); + } + } + if (signedEvent == null) { + return null; + } + + if (TextUtils.isEmpty(signedEvent.optString("pubkey", null))) { + signedEvent.put("pubkey", unsignedEvent.optString("pubkey", "")); + } + if (TextUtils.isEmpty(signedEvent.optString("id", null))) { + signedEvent.put("id", fallbackEventId); + } + return signedEvent; + } catch (final JSONException e) { + Log.w(TAG, "Invalid signer event response", e); + return null; + } + } + + @Nullable + private static QueryResult querySigner(@NonNull final Context context, + @NonNull final String signerPackage, + @NonNull final String operation, + @NonNull final String[] arguments) { + final Uri uri = Uri.parse("content://" + signerPackage + "." + operation); + final ContentResolver contentResolver = context.getContentResolver(); + try (Cursor cursor = contentResolver.query(uri, arguments, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return null; + } + + final String result = firstNonEmpty( + readCursorString(cursor, "result"), + readCursorString(cursor, "signature"), + readCursorString(cursor, "event"), + readCursorString(cursor, 0) + ); + final String eventJson = readCursorString(cursor, "event"); + final boolean rejected = readCursorBoolean(cursor, "rejected"); + final String errorMessage = readCursorString(cursor, "error"); + return new QueryResult(result, eventJson, rejected, errorMessage); + } catch (final SecurityException e) { + Log.w(TAG, "Signer query blocked by OS permissions", e); + return null; + } catch (final RuntimeException e) { + Log.w(TAG, "Signer query failed for " + operation, e); + return null; + } + } + + private static boolean readCursorBoolean(@NonNull final Cursor cursor, + @NonNull final String columnName) { + final int index = cursor.getColumnIndex(columnName); + if (index < 0 || cursor.isNull(index)) { + return false; + } + final String value = cursor.getString(index); + if (TextUtils.isEmpty(value)) { + return false; + } + return "1".equals(value) || "true".equalsIgnoreCase(value); + } + + @Nullable + private static String readCursorString(@NonNull final Cursor cursor, + @NonNull final String columnName) { + final int index = cursor.getColumnIndex(columnName); + if (index < 0 || cursor.isNull(index)) { + return null; + } + final String value = cursor.getString(index); + return TextUtils.isEmpty(value) ? null : value; + } + + @Nullable + private static String readCursorString(@NonNull final Cursor cursor, + final int columnIndex) { + if (columnIndex < 0 + || columnIndex >= cursor.getColumnCount() + || cursor.isNull(columnIndex)) { + return null; + } + final String value = cursor.getString(columnIndex); + return TextUtils.isEmpty(value) ? null : value; + } + + @Nullable + private static String firstNonEmpty(@Nullable final String... values) { + if (values == null) { + return null; + } + for (final String value : values) { + if (!TextUtils.isEmpty(value)) { + return value; + } + } + return null; + } + + private static final class QueryResult { + @Nullable + final String result; + @Nullable + final String eventJson; + final boolean rejected; + @Nullable + final String errorMessage; + + QueryResult(@Nullable final String result, + @Nullable final String eventJson, + final boolean rejected, + @Nullable final String errorMessage) { + this.result = result; + this.eventJson = eventJson; + this.rejected = rejected; + this.errorMessage = errorMessage; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java new file mode 100644 index 00000000000..74e28cabf85 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java @@ -0,0 +1,761 @@ +package org.schabi.newpipe.local.nostr; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.engines.ChaCha7539Engine; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.macs.HMac; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.math.ec.ECPoint; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class NostrKeyUtils { + private static final String BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + private static final int[] BECH32_GENERATOR = { + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3 + }; + private static final Pattern NSEC_PATTERN = Pattern.compile( + "(nsec1[" + BECH32_CHARSET + "]+)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern HEX_PATTERN = Pattern.compile("^[0-9a-fA-F]+$"); + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final QRCodeWriter QR_CODE_WRITER = new QRCodeWriter(); + private static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1"); + private static final BigInteger CURVE_ORDER = + CURVE_PARAMS == null ? BigInteger.ZERO : CURVE_PARAMS.getN(); + + private NostrKeyUtils() { + } + + @NonNull + static NostrIdentity generateIdentity() { + final byte[] privateKey = generatePrivateKeyBytes(); + final byte[] publicKey = deriveXOnlyPublicKey(privateKey); + + final String nsec = encodeBech32("nsec", convertBits(privateKey, 8, 5, true)); + final String npub = encodeBech32("npub", convertBits(publicKey, 8, 5, true)); + return new NostrIdentity(nsec, npub); + } + + @NonNull + static NostrIdentity fromScannedNsec(@Nullable final String rawNsec) { + if (rawNsec == null || rawNsec.trim().isEmpty()) { + throw new IllegalArgumentException("Missing nsec value"); + } + + final String candidate = extractNsecCandidate(rawNsec); + final DecodedBech32 decoded = decodeBech32(candidate); + if (!"nsec".equals(decoded.hrp)) { + throw new IllegalArgumentException("QR code does not contain nsec"); + } + + final byte[] privateKey = convertBits(decoded.data, 5, 8, false); + validatePrivateKey(privateKey); + final byte[] publicKey = deriveXOnlyPublicKey(privateKey); + + final String nsec = encodeBech32("nsec", convertBits(privateKey, 8, 5, true)); + final String npub = encodeBech32("npub", convertBits(publicKey, 8, 5, true)); + return new NostrIdentity(nsec, npub); + } + + @NonNull + static String toNpub(@Nullable final String rawPublicKey) { + if (rawPublicKey == null || rawPublicKey.trim().isEmpty()) { + throw new IllegalArgumentException("Missing public key value"); + } + + String candidate = rawPublicKey.trim(); + if (candidate.toLowerCase(Locale.US).startsWith("nostr:")) { + candidate = candidate.substring("nostr:".length()); + } + final String normalized = candidate.toLowerCase(Locale.US); + + if (normalized.startsWith("npub1")) { + final DecodedBech32 decoded = decodeBech32(normalized); + if (!"npub".equals(decoded.hrp)) { + throw new IllegalArgumentException("Expected npub key"); + } + final byte[] publicKey = convertBits(decoded.data, 5, 8, false); + if (publicKey.length != 32) { + throw new IllegalArgumentException("Invalid public key length"); + } + return encodeBech32("npub", convertBits(publicKey, 8, 5, true)); + } + + final byte[] publicKey = hexToBytes(normalized); + if (publicKey.length != 32) { + throw new IllegalArgumentException("Invalid public key length"); + } + return encodeBech32("npub", convertBits(publicKey, 8, 5, true)); + } + + @NonNull + static String toPublicKeyHex(@Nullable final String rawPublicKey) { + if (rawPublicKey == null || rawPublicKey.trim().isEmpty()) { + throw new IllegalArgumentException("Missing public key value"); + } + + String candidate = rawPublicKey.trim(); + if (candidate.toLowerCase(Locale.US).startsWith("nostr:")) { + candidate = candidate.substring("nostr:".length()); + } + final String normalized = candidate.toLowerCase(Locale.US); + + if (normalized.startsWith("npub1")) { + final DecodedBech32 decoded = decodeBech32(normalized); + if (!"npub".equals(decoded.hrp)) { + throw new IllegalArgumentException("Expected npub key"); + } + final byte[] publicKey = convertBits(decoded.data, 5, 8, false); + if (publicKey.length != 32) { + throw new IllegalArgumentException("Invalid public key length"); + } + return bytesToHex(publicKey); + } + + final byte[] publicKey = hexToBytes(normalized); + if (publicKey.length != 32) { + throw new IllegalArgumentException("Invalid public key length"); + } + return bytesToHex(publicKey); + } + + @NonNull + static byte[] decodeNsecPrivateKey(@Nullable final String nsec) { + if (nsec == null || nsec.trim().isEmpty()) { + throw new IllegalArgumentException("Missing nsec value"); + } + final String candidate = extractNsecCandidate(nsec); + final DecodedBech32 decoded = decodeBech32(candidate); + if (!"nsec".equals(decoded.hrp)) { + throw new IllegalArgumentException("Expected nsec key"); + } + + final byte[] privateKey = convertBits(decoded.data, 5, 8, false); + validatePrivateKey(privateKey); + return privateKey; + } + + @NonNull + static String derivePublicKeyHexFromNsec(@NonNull final String nsec) { + final byte[] privateKey = decodeNsecPrivateKey(nsec); + final byte[] publicKey = deriveXOnlyPublicKey(privateKey); + return bytesToHex(publicKey); + } + + @NonNull + static String signEventId(@NonNull final String nsec, @NonNull final byte[] messageHash) { + if (messageHash.length != 32) { + throw new IllegalArgumentException("Event hash must be 32 bytes"); + } + final byte[] privateKey = decodeNsecPrivateKey(nsec); + return signSchnorrBip340(privateKey, messageHash); + } + + @NonNull + static String encryptNip44(@NonNull final String nsec, + @NonNull final String recipientPubKeyHex, + @NonNull final String plainText) { + final byte[] privateKey = decodeNsecPrivateKey(nsec); + final byte[] sharedSecret = deriveSharedSecret(privateKey, recipientPubKeyHex); + final byte[] conversationKey = hkdfExtract( + sharedSecret, + "nip44-v2".getBytes(StandardCharsets.UTF_8) + ); + final byte[] nonce = new byte[32]; + SECURE_RANDOM.nextBytes(nonce); + + final MessageKeys keys = deriveMessageKeys(conversationKey, nonce); + final byte[] padded = padNip44(plainText.getBytes(StandardCharsets.UTF_8)); + final byte[] cipherText = chacha20(keys.chachaKey, keys.chachaNonce, padded); + final byte[] mac = hmacSha256(keys.hmacKey, concatenate(nonce, cipherText)); + + final byte[] payload = concatenate( + new byte[]{0x02}, + nonce, + cipherText, + mac + ); + return Base64.encodeToString(payload, Base64.NO_WRAP); + } + + @NonNull + static String decryptNip44(@NonNull final String nsec, + @NonNull final String senderPubKeyHex, + @NonNull final String payload) { + if (payload.isEmpty() || payload.charAt(0) == '#') { + throw new IllegalArgumentException("Unsupported NIP-44 payload version"); + } + if (payload.length() < 132 || payload.length() > 87472) { + throw new IllegalArgumentException("Invalid NIP-44 payload length"); + } + + final byte[] decoded = Base64.decode(payload, Base64.DEFAULT); + if (decoded.length < 99 || decoded.length > 65603) { + throw new IllegalArgumentException("Invalid NIP-44 decoded payload length"); + } + if ((decoded[0] & 0xff) != 2) { + throw new IllegalArgumentException("Unsupported NIP-44 payload version"); + } + + final byte[] nonce = Arrays.copyOfRange(decoded, 1, 33); + final byte[] cipherText = Arrays.copyOfRange(decoded, 33, decoded.length - 32); + final byte[] receivedMac = Arrays.copyOfRange(decoded, decoded.length - 32, decoded.length); + + final byte[] privateKey = decodeNsecPrivateKey(nsec); + final byte[] sharedSecret = deriveSharedSecret(privateKey, senderPubKeyHex); + final byte[] conversationKey = hkdfExtract( + sharedSecret, + "nip44-v2".getBytes(StandardCharsets.UTF_8) + ); + final MessageKeys keys = deriveMessageKeys(conversationKey, nonce); + final byte[] expectedMac = hmacSha256(keys.hmacKey, concatenate(nonce, cipherText)); + if (!MessageDigest.isEqual(expectedMac, receivedMac)) { + throw new IllegalArgumentException("Invalid NIP-44 MAC"); + } + + final byte[] padded = chacha20(keys.chachaKey, keys.chachaNonce, cipherText); + final byte[] unpadded = unpadNip44(padded); + return new String(unpadded, StandardCharsets.UTF_8); + } + + @NonNull + static Bitmap generateQrCode(@NonNull final String text, final int sizePx) { + try { + final BitMatrix matrix = QR_CODE_WRITER.encode( + text, BarcodeFormat.QR_CODE, sizePx, sizePx + ); + final Bitmap bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.RGB_565); + for (int y = 0; y < sizePx; y++) { + for (int x = 0; x < sizePx; x++) { + bitmap.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE); + } + } + return bitmap; + } catch (final WriterException e) { + throw new IllegalArgumentException("Could not generate QR code", e); + } + } + + private static void validatePrivateKey(@NonNull final byte[] privateKeyBytes) { + ensureCurveAvailable(); + if (privateKeyBytes.length != 32) { + throw new IllegalArgumentException("Invalid nsec key length"); + } + final BigInteger privateKey = new BigInteger(1, privateKeyBytes); + if (privateKey.signum() == 0 || privateKey.compareTo(CURVE_ORDER) >= 0) { + throw new IllegalArgumentException("Invalid nsec key value"); + } + } + + @NonNull + private static byte[] generatePrivateKeyBytes() { + ensureCurveAvailable(); + final byte[] candidate = new byte[32]; + while (true) { + SECURE_RANDOM.nextBytes(candidate); + final BigInteger value = new BigInteger(1, candidate); + if (value.signum() > 0 && value.compareTo(CURVE_ORDER) < 0) { + return candidate; + } + } + } + + @NonNull + private static byte[] deriveXOnlyPublicKey(@NonNull final byte[] privateKeyBytes) { + ensureCurveAvailable(); + + final BigInteger privateKey = new BigInteger(1, privateKeyBytes); + final ECPoint publicPoint = CURVE_PARAMS.getG().multiply(privateKey).normalize(); + return toFixedSizeBytes(publicPoint.getAffineXCoord().toBigInteger(), 32); + } + + @NonNull + private static String signSchnorrBip340(@NonNull final byte[] privateKeyBytes, + @NonNull final byte[] messageHash) { + ensureCurveAvailable(); + validatePrivateKey(privateKeyBytes); + if (messageHash.length != 32) { + throw new IllegalArgumentException("Schnorr message must be 32 bytes"); + } + + final BigInteger d0 = new BigInteger(1, privateKeyBytes); + final ECPoint publicPoint = CURVE_PARAMS.getG().multiply(d0).normalize(); + final BigInteger d = publicPoint.getAffineYCoord().toBigInteger().testBit(0) + ? CURVE_ORDER.subtract(d0) + : d0; + final byte[] publicKeyX = toFixedSizeBytes( + publicPoint.getAffineXCoord().toBigInteger(), + 32 + ); + + final byte[] auxRand = new byte[32]; + SECURE_RANDOM.nextBytes(auxRand); + final byte[] dBytes = toFixedSizeBytes(d, 32); + final byte[] auxHash = taggedHash("BIP0340/aux", auxRand); + final byte[] t = xor(dBytes, auxHash); + + final byte[] nonceInput = concatenate(t, publicKeyX, messageHash); + final BigInteger k0 = new BigInteger(1, taggedHash("BIP0340/nonce", nonceInput)) + .mod(CURVE_ORDER); + if (k0.signum() == 0) { + throw new IllegalStateException("Invalid Schnorr nonce"); + } + + final ECPoint noncePoint = CURVE_PARAMS.getG().multiply(k0).normalize(); + final BigInteger k = noncePoint.getAffineYCoord().toBigInteger().testBit(0) + ? CURVE_ORDER.subtract(k0) + : k0; + final byte[] nonceX = toFixedSizeBytes(noncePoint.getAffineXCoord().toBigInteger(), 32); + + final byte[] challengeInput = concatenate(nonceX, publicKeyX, messageHash); + final BigInteger challenge = new BigInteger( + 1, taggedHash("BIP0340/challenge", challengeInput) + ).mod(CURVE_ORDER); + + final BigInteger s = k.add(challenge.multiply(d)).mod(CURVE_ORDER); + final byte[] signature = concatenate(nonceX, toFixedSizeBytes(s, 32)); + return bytesToHex(signature); + } + + @NonNull + private static byte[] deriveSharedSecret(@NonNull final byte[] privateKeyBytes, + @NonNull final String pubKeyHex) { + ensureCurveAvailable(); + validatePrivateKey(privateKeyBytes); + final ECPoint publicPoint = decodePublicPoint(pubKeyHex); + final BigInteger privateKey = new BigInteger(1, privateKeyBytes); + final ECPoint sharedPoint = publicPoint.multiply(privateKey).normalize(); + if (sharedPoint.isInfinity()) { + throw new IllegalArgumentException("Invalid shared point"); + } + return toFixedSizeBytes(sharedPoint.getAffineXCoord().toBigInteger(), 32); + } + + @NonNull + private static MessageKeys deriveMessageKeys(@NonNull final byte[] conversationKey, + @NonNull final byte[] nonce) { + if (conversationKey.length != 32) { + throw new IllegalArgumentException("Invalid NIP-44 conversation key length"); + } + if (nonce.length != 32) { + throw new IllegalArgumentException("Invalid NIP-44 nonce length"); + } + + final byte[] expanded = hkdfExpand(conversationKey, nonce, 76); + final byte[] chachaKey = Arrays.copyOfRange(expanded, 0, 32); + final byte[] chachaNonce = Arrays.copyOfRange(expanded, 32, 44); + final byte[] hmacKey = Arrays.copyOfRange(expanded, 44, 76); + return new MessageKeys(chachaKey, chachaNonce, hmacKey); + } + + @NonNull + private static byte[] hkdfExtract(@NonNull final byte[] ikm, @NonNull final byte[] salt) { + return hmacSha256(salt, ikm); + } + + @NonNull + private static byte[] hkdfExpand(@NonNull final byte[] prk, + @NonNull final byte[] info, + final int outputLength) { + if (outputLength <= 0 || outputLength > 255 * 32) { + throw new IllegalArgumentException("Invalid HKDF output length"); + } + + final ByteArrayOutputStream output = new ByteArrayOutputStream(outputLength); + byte[] previous = new byte[0]; + int counter = 1; + while (output.size() < outputLength) { + final byte[] blockInput = concatenate(previous, info, new byte[]{(byte) counter}); + previous = hmacSha256(prk, blockInput); + final int remaining = outputLength - output.size(); + output.write(previous, 0, Math.min(previous.length, remaining)); + counter++; + } + return output.toByteArray(); + } + + @NonNull + private static byte[] hmacSha256(@NonNull final byte[] key, @NonNull final byte[] message) { + final HMac hMac = new HMac(new SHA256Digest()); + hMac.init(new KeyParameter(key)); + hMac.update(message, 0, message.length); + final byte[] output = new byte[hMac.getMacSize()]; + hMac.doFinal(output, 0); + return output; + } + + @NonNull + private static byte[] chacha20(@NonNull final byte[] key, + @NonNull final byte[] nonce, + @NonNull final byte[] input) { + if (key.length != 32 || nonce.length != 12) { + throw new IllegalArgumentException("Invalid ChaCha20 key or nonce length"); + } + final ChaCha7539Engine engine = new ChaCha7539Engine(); + engine.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + + final byte[] output = new byte[input.length]; + engine.processBytes(input, 0, input.length, output, 0); + return output; + } + + @NonNull + private static byte[] padNip44(@NonNull final byte[] plaintext) { + final int unpaddedLength = plaintext.length; + if (unpaddedLength < 1 || unpaddedLength > 65535) { + throw new IllegalArgumentException("Invalid NIP-44 plaintext length"); + } + + final int paddedLength = calculatePaddedLength(unpaddedLength); + final byte[] output = new byte[2 + paddedLength]; + output[0] = (byte) ((unpaddedLength >>> 8) & 0xff); + output[1] = (byte) (unpaddedLength & 0xff); + System.arraycopy(plaintext, 0, output, 2, unpaddedLength); + return output; + } + + @NonNull + private static byte[] unpadNip44(@NonNull final byte[] padded) { + if (padded.length < 34) { + throw new IllegalArgumentException("Invalid NIP-44 padded payload"); + } + final int unpaddedLength = ((padded[0] & 0xff) << 8) | (padded[1] & 0xff); + if (unpaddedLength < 1) { + throw new IllegalArgumentException("Invalid NIP-44 unpadded length"); + } + final int expectedLength = 2 + calculatePaddedLength(unpaddedLength); + if (padded.length != expectedLength || 2 + unpaddedLength > padded.length) { + throw new IllegalArgumentException("Invalid NIP-44 padding size"); + } + return Arrays.copyOfRange(padded, 2, 2 + unpaddedLength); + } + + private static int calculatePaddedLength(final int unpaddedLength) { + if (unpaddedLength <= 32) { + return 32; + } + + final int nextPower = 1 << (32 - Integer.numberOfLeadingZeros(unpaddedLength - 1)); + final int chunk = nextPower <= 256 ? 32 : nextPower / 8; + return chunk * ((unpaddedLength - 1) / chunk + 1); + } + + @NonNull + private static ECPoint decodePublicPoint(@NonNull final String rawPubKeyHex) { + ensureCurveAvailable(); + final byte[] pubKeyBytes = hexToBytes(rawPubKeyHex); + if (pubKeyBytes.length == 32) { + final byte[] compressed = new byte[33]; + compressed[0] = 0x02; + System.arraycopy(pubKeyBytes, 0, compressed, 1, 32); + return CURVE_PARAMS.getCurve().decodePoint(compressed).normalize(); + } + if (pubKeyBytes.length == 33 || pubKeyBytes.length == 65) { + return CURVE_PARAMS.getCurve().decodePoint(pubKeyBytes).normalize(); + } + throw new IllegalArgumentException("Invalid public key format"); + } + + private static void ensureCurveAvailable() { + if (CURVE_PARAMS == null || CURVE_ORDER.equals(BigInteger.ZERO)) { + throw new IllegalStateException("secp256k1 curve unavailable"); + } + } + + @NonNull + private static byte[] toFixedSizeBytes(@NonNull final BigInteger value, final int size) { + final byte[] raw = value.toByteArray(); + if (raw.length == size) { + return raw; + } + + final byte[] output = new byte[size]; + if (raw.length > size) { + System.arraycopy(raw, raw.length - size, output, 0, size); + } else { + System.arraycopy(raw, 0, output, size - raw.length, raw.length); + } + return output; + } + + @NonNull + private static String extractNsecCandidate(@NonNull final String input) { + final String normalized = input.trim().toLowerCase(Locale.US); + final String withoutPrefix = normalized.startsWith("nostr:") + ? normalized.substring("nostr:".length()) + : normalized; + + if (withoutPrefix.startsWith("nsec1")) { + return withoutPrefix; + } + + final Matcher matcher = NSEC_PATTERN.matcher(withoutPrefix); + if (matcher.find()) { + return matcher.group(1); + } + + throw new IllegalArgumentException("No nsec key found in QR code"); + } + + @NonNull + private static byte[] hexToBytes(@NonNull final String rawHex) { + final String hex = rawHex.startsWith("0x") ? rawHex.substring(2) : rawHex; + if (hex.isEmpty() || (hex.length() & 1) != 0 || !HEX_PATTERN.matcher(hex).matches()) { + throw new IllegalArgumentException("Invalid hex public key"); + } + + final byte[] output = new byte[hex.length() / 2]; + for (int i = 0; i < output.length; i++) { + final int high = Character.digit(hex.charAt(i * 2), 16); + final int low = Character.digit(hex.charAt(i * 2 + 1), 16); + output[i] = (byte) ((high << 4) | low); + } + return output; + } + + @NonNull + private static String bytesToHex(@NonNull final byte[] bytes) { + final char[] hexChars = new char[bytes.length * 2]; + final char[] alphabet = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + final int value = bytes[i] & 0xff; + hexChars[i * 2] = alphabet[value >>> 4]; + hexChars[i * 2 + 1] = alphabet[value & 0x0f]; + } + return new String(hexChars); + } + + @NonNull + private static byte[] taggedHash(@NonNull final String tag, @NonNull final byte[] data) { + final byte[] tagHash = sha256(tag.getBytes(StandardCharsets.UTF_8)); + return sha256(concatenate(tagHash, tagHash, data)); + } + + @NonNull + private static byte[] sha256(@NonNull final byte[] data) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(data); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + @NonNull + private static byte[] xor(@NonNull final byte[] left, @NonNull final byte[] right) { + if (left.length != right.length) { + throw new IllegalArgumentException("Mismatched xor input lengths"); + } + final byte[] output = new byte[left.length]; + for (int i = 0; i < left.length; i++) { + output[i] = (byte) (left[i] ^ right[i]); + } + return output; + } + + @NonNull + private static String encodeBech32(@NonNull final String hrp, @NonNull final byte[] data) { + final String lowerHrp = hrp.toLowerCase(Locale.US); + final byte[] checksum = createChecksum(lowerHrp, data); + final StringBuilder builder = new StringBuilder( + lowerHrp.length() + 1 + data.length + checksum.length + ); + builder.append(lowerHrp).append('1'); + + for (final byte value : data) { + builder.append(BECH32_CHARSET.charAt(value)); + } + for (final byte value : checksum) { + builder.append(BECH32_CHARSET.charAt(value)); + } + return builder.toString(); + } + + @NonNull + private static DecodedBech32 decodeBech32(@NonNull final String value) { + final String bech32 = value.trim().toLowerCase(Locale.US); + final int separatorIndex = bech32.lastIndexOf('1'); + if (separatorIndex < 1 || separatorIndex + 7 > bech32.length()) { + throw new IllegalArgumentException("Invalid bech32 value"); + } + + final String hrp = bech32.substring(0, separatorIndex); + final byte[] data = new byte[bech32.length() - separatorIndex - 1]; + for (int i = 0; i < data.length; i++) { + final int charIndex = BECH32_CHARSET.indexOf(bech32.charAt(separatorIndex + 1 + i)); + if (charIndex < 0) { + throw new IllegalArgumentException("Invalid bech32 characters"); + } + data[i] = (byte) charIndex; + } + + if (!verifyChecksum(hrp, data)) { + throw new IllegalArgumentException("Invalid bech32 checksum"); + } + + return new DecodedBech32(hrp, Arrays.copyOf(data, data.length - 6)); + } + + @NonNull + private static byte[] createChecksum(@NonNull final String hrp, @NonNull final byte[] data) { + final byte[] values = concatenate(hrpExpand(hrp), data, new byte[6]); + final int polymod = bech32Polymod(values) ^ 1; + + final byte[] checksum = new byte[6]; + for (int i = 0; i < 6; i++) { + checksum[i] = (byte) ((polymod >> (5 * (5 - i))) & 0x1f); + } + return checksum; + } + + private static boolean verifyChecksum(@NonNull final String hrp, @NonNull final byte[] data) { + return bech32Polymod(concatenate(hrpExpand(hrp), data)) == 1; + } + + @NonNull + private static byte[] hrpExpand(@NonNull final String hrp) { + final byte[] output = new byte[hrp.length() * 2 + 1]; + for (int i = 0; i < hrp.length(); i++) { + output[i] = (byte) (hrp.charAt(i) >> 5); + output[i + hrp.length() + 1] = (byte) (hrp.charAt(i) & 0x1f); + } + output[hrp.length()] = 0; + return output; + } + + private static int bech32Polymod(@NonNull final byte[] values) { + int checksum = 1; + for (final byte value : values) { + final int top = checksum >>> 25; + checksum = (checksum & 0x1ffffff) << 5 ^ (value & 0xff); + for (int i = 0; i < 5; i++) { + if (((top >>> i) & 1) != 0) { + checksum ^= BECH32_GENERATOR[i]; + } + } + } + return checksum; + } + + @NonNull + private static byte[] concatenate(@NonNull final byte[]... arrays) { + int totalLength = 0; + for (final byte[] array : arrays) { + totalLength += array.length; + } + + final byte[] output = new byte[totalLength]; + int position = 0; + for (final byte[] array : arrays) { + System.arraycopy(array, 0, output, position, array.length); + position += array.length; + } + return output; + } + + @NonNull + private static byte[] convertBits(@NonNull final byte[] data, + final int fromBits, + final int toBits, + final boolean pad) { + final int maxValue = (1 << toBits) - 1; + int accumulator = 0; + int bits = 0; + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + for (final byte value : data) { + final int intValue = value & 0xff; + if ((intValue >>> fromBits) != 0) { + throw new IllegalArgumentException("Input value exceeds bit size"); + } + + accumulator = (accumulator << fromBits) | intValue; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + output.write((accumulator >>> bits) & maxValue); + } + } + + if (pad) { + if (bits > 0) { + output.write((accumulator << (toBits - bits)) & maxValue); + } + } else if (bits >= fromBits || ((accumulator << (toBits - bits)) & maxValue) != 0) { + throw new IllegalArgumentException("Invalid padding in bech32 value"); + } + + return output.toByteArray(); + } + + private static final class MessageKeys { + @NonNull + final byte[] chachaKey; + @NonNull + final byte[] chachaNonce; + @NonNull + final byte[] hmacKey; + + MessageKeys(@NonNull final byte[] chachaKey, + @NonNull final byte[] chachaNonce, + @NonNull final byte[] hmacKey) { + this.chachaKey = chachaKey; + this.chachaNonce = chachaNonce; + this.hmacKey = hmacKey; + } + } + + static final class NostrIdentity { + @NonNull + final String nsec; + @NonNull + final String npub; + + NostrIdentity(@NonNull final String nsec, @NonNull final String npub) { + this.nsec = nsec; + this.npub = npub; + } + } + + private static final class DecodedBech32 { + @NonNull + final String hrp; + @NonNull + final byte[] data; + + DecodedBech32(@NonNull final String hrp, @NonNull final byte[] data) { + this.hrp = hrp; + this.data = data; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java new file mode 100644 index 00000000000..e711017042f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java @@ -0,0 +1,1045 @@ +package org.schabi.newpipe.local.nostr; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.RelativeSizeSpan; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.SwitchCompat; +import androidx.preference.PreferenceManager; + +import com.journeyapps.barcodescanner.ScanContract; +import com.journeyapps.barcodescanner.ScanIntentResult; +import com.journeyapps.barcodescanner.ScanOptions; + +import org.schabi.newpipe.BaseFragment; +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.CoilHelper; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class NostrSyncFragment extends BaseFragment { + private static final String PREF_NOSTR_SYNC_ENABLED = "nostr_sync_enabled"; + private static final String PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED = + "nostr_sync_watch_history_enabled"; + private static final String PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED = + "nostr_sync_subscriptions_enabled"; + private static final String PREF_NOSTR_ENABLED_RELAYS = "nostr_enabled_relays"; + private static final String PREF_NOSTR_RELAYS = "nostr_relays"; + private static final String PREF_NOSTR_NSEC = "nostr_nsec"; + private static final String PREF_NOSTR_NPUB = "nostr_npub"; + private static final String PREF_NOSTR_EXTERNAL_SIGNER = "nostr_external_signer"; + private static final String PREF_NOSTR_SIGNER_PACKAGE = "nostr_signer_package"; + private static final String PREF_NOSTR_PROFILE_NAME = "nostr_profile_name"; + private static final String PREF_NOSTR_PROFILE_DISPLAY_NAME = "nostr_profile_display_name"; + private static final String PREF_NOSTR_PROFILE_PICTURE_URL = "nostr_profile_picture_url"; + private static final String AMBER_PACKAGE_NAME = "com.greenart7c3.nostrsigner"; + private static final String PRIMAL_PACKAGE_NAME = "net.primal.android"; + private static final String NIP55_URI = "nostrsigner:"; + private static final String NIP55_TYPE = "type"; + private static final String NIP55_TYPE_GET_PUBLIC_KEY = "get_public_key"; + private static final String NIP55_PERMISSIONS = "permissions"; + private static final String NIP55_RESULT = "result"; + private static final String NIP55_SIGNATURE = "signature"; + private static final String NIP55_PUBKEY = "pubkey"; + private static final String NIP55_NPUB = "npub"; + private static final String NIP55_PACKAGE = "package"; + private static final String NIP55_RESULTS = "results"; + private static final String NIP55_PERMISSION_TYPE = "type"; + private static final String NIP55_PERMISSION_KIND = "kind"; + private static final String NIP55_PERMISSION_SIGN_EVENT = "sign_event"; + private static final String NIP55_PERMISSION_NIP44_ENCRYPT = "nip44_encrypt"; + private static final String NIP55_PERMISSION_NIP44_DECRYPT = "nip44_decrypt"; + private static final int NOSTR_SYNC_APP_DATA_KIND = 30078; + private static final Set DEFAULT_ENABLED_RELAYS = new HashSet<>(Arrays.asList( + "wss://relay.primal.net", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://nostr.oxtr.dev", + "wss://nos.lol", + "wss://nostr.bitcoiner.social", + "wss://nostr.semisol.dev" + )); + private static final String MASKED_NSEC = + "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF"; + private static final List DEFAULT_RELAYS = Arrays.asList( + "wss://relay.primal.net", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://nostr.oxtr.dev", + "wss://nos.lol", + "wss://nostr.bitcoiner.social", + "wss://nostr.semisol.dev", + "wss://shu01.shugur.net", + "wss://shu02.shugur.net", + "wss://shu03.shugur.net", + "wss://shu04.shugur.net", + "wss://shu05.shugur.net" + ); + + private SharedPreferences preferences; + private SwitchCompat syncWatchHistorySwitch; + private SwitchCompat syncSubscriptionsSwitch; + private Button signInButton; + private Button showIdentityButton; + private Button clearIdentityButton; + private Button addRelayButton; + private Button resetRelaysButton; + private LinearLayout identityActionsContainer; + private LinearLayout relaysContainer; + @Nullable + private AlertDialog signInDialog; + @Nullable + private String pendingSignerPackage; + + private final ActivityResultLauncher scanNsecLauncher = + registerForActivityResult(new ScanContract(), this::onNsecScanResult); + private final ActivityResultLauncher nip55RequestLauncher = + registerForActivityResult(new StartActivityForResult(), this::onNip55SignerResult); + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_nostr_sync, container, false); + } + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + + syncWatchHistorySwitch = rootView.findViewById(R.id.nostr_sync_watch_history_switch); + syncSubscriptionsSwitch = rootView.findViewById(R.id.nostr_sync_subscriptions_switch); + signInButton = rootView.findViewById(R.id.nostr_sign_in_button); + identityActionsContainer = rootView.findViewById(R.id.nostr_identity_actions_container); + showIdentityButton = rootView.findViewById(R.id.nostr_show_identity_button); + clearIdentityButton = rootView.findViewById(R.id.nostr_clear_identity_button); + addRelayButton = rootView.findViewById(R.id.nostr_add_relay_button); + resetRelaysButton = rootView.findViewById(R.id.nostr_reset_relays_button); + relaysContainer = rootView.findViewById(R.id.nostr_relays_container); + + final boolean legacyDefault = preferences.getBoolean(PREF_NOSTR_SYNC_ENABLED, false); + syncWatchHistorySwitch.setChecked(preferences.getBoolean( + PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED, legacyDefault)); + syncSubscriptionsSwitch.setChecked(preferences.getBoolean( + PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED, legacyDefault)); + + syncWatchHistorySwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + preferences.edit().putBoolean( + PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED, isChecked).apply(); + requestSyncIfEligible(); + }); + syncSubscriptionsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + preferences.edit().putBoolean( + PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED, isChecked).apply(); + requestSyncIfEligible(); + }); + + signInButton.setOnClickListener(v -> showSignInDialog()); + showIdentityButton.setOnClickListener(v -> showIdentityDialog()); + clearIdentityButton.setOnClickListener(v -> showClearIdentityDialog()); + addRelayButton.setOnClickListener(v -> showAddRelayDialog()); + resetRelaysButton.setOnClickListener(v -> resetRelaysToDefault()); + + populateRelays(); + updateIdentityButtons(); + } + + @Override + public void onDestroyView() { + if (signInDialog != null) { + signInDialog.dismiss(); + signInDialog = null; + } + super.onDestroyView(); + } + + @Override + public void onResume() { + super.onResume(); + setTitle(getString(R.string.nostr_sync)); + requestSyncIfEligible(); + } + + private void populateRelays() { + relaysContainer.removeAllViews(); + final List relays = getRelayList(); + final Set enabledRelays = getEnabledRelays(relays); + + for (final String relay : relays) { + final LinearLayout row = new LinearLayout(requireContext()); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + final CheckBox relayCheckBox = new CheckBox(requireContext()); + relayCheckBox.setLayoutParams(new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + )); + relayCheckBox.setText(relay); + relayCheckBox.setChecked(enabledRelays.contains(relay)); + relayCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + final Set updatedRelays = getEnabledRelays(getRelayList()); + if (isChecked) { + updatedRelays.add(relay); + } else { + updatedRelays.remove(relay); + } + saveEnabledRelays(updatedRelays); + }); + + final ImageButton removeRelayButton = new ImageButton(requireContext()); + final int buttonSizePx = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 40, + getResources().getDisplayMetrics() + ); + removeRelayButton.setLayoutParams(new LinearLayout.LayoutParams( + buttonSizePx, + buttonSizePx + )); + removeRelayButton.setImageResource(R.drawable.ic_delete); + removeRelayButton.setBackgroundResource(android.R.color.transparent); + removeRelayButton.setContentDescription(getString(R.string.nostr_remove_relay)); + removeRelayButton.setOnClickListener(v -> removeRelay(relay)); + + row.addView(relayCheckBox); + row.addView(removeRelayButton); + relaysContainer.addView(row); + } + } + + @NonNull + private Set getEnabledRelays(@NonNull final List relays) { + final Set saved = preferences.getStringSet(PREF_NOSTR_ENABLED_RELAYS, null); + final Set allowedRelays = new HashSet<>(relays); + if (saved != null) { + final Set filtered = new HashSet<>(saved); + filtered.retainAll(allowedRelays); + return filtered; + } + final Set defaults = new HashSet<>(DEFAULT_ENABLED_RELAYS); + defaults.retainAll(allowedRelays); + return defaults; + } + + @NonNull + private List getRelayList() { + final String raw = preferences.getString(PREF_NOSTR_RELAYS, null); + if (TextUtils.isEmpty(raw)) { + return DEFAULT_RELAYS; + } + + final List relays = new java.util.ArrayList<>(); + final Set seen = new HashSet<>(); + try { + final JSONArray jsonArray = new JSONArray(raw); + for (int i = 0; i < jsonArray.length(); i++) { + final String relay = jsonArray.optString(i, "").trim(); + if (TextUtils.isEmpty(relay) || !seen.add(relay)) { + continue; + } + relays.add(relay); + } + } catch (final JSONException ignored) { + return DEFAULT_RELAYS; + } + return relays; + } + + private void saveRelayList(@NonNull final List relays) { + final JSONArray jsonArray = new JSONArray(); + for (final String relay : relays) { + jsonArray.put(relay); + } + preferences.edit().putString(PREF_NOSTR_RELAYS, jsonArray.toString()).apply(); + } + + private void saveEnabledRelays(@NonNull final Set enabledRelays) { + preferences.edit() + .putStringSet(PREF_NOSTR_ENABLED_RELAYS, new HashSet<>(enabledRelays)) + .apply(); + } + + private void showAddRelayDialog() { + final EditText input = new EditText(requireContext()); + input.setSingleLine(true); + input.setHint(R.string.nostr_add_relay_hint); + + final AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setTitle(R.string.nostr_add_relay_title) + .setView(input) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.nostr_add_relay, null) + .create(); + dialog.show(); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + final String relay = input.getText() == null ? "" : input.getText().toString().trim(); + if (TextUtils.isEmpty(relay) + || (!relay.startsWith("wss://") && !relay.startsWith("ws://"))) { + Toast.makeText(requireContext(), R.string.nostr_invalid_relay_url, + Toast.LENGTH_LONG).show(); + return; + } + + final List relays = new java.util.ArrayList<>(getRelayList()); + for (final String existingRelay : relays) { + if (relay.equalsIgnoreCase(existingRelay)) { + Toast.makeText(requireContext(), R.string.nostr_relay_already_exists, + Toast.LENGTH_LONG).show(); + return; + } + } + + relays.add(relay); + saveRelayList(relays); + + final Set enabledRelays = getEnabledRelays(relays); + enabledRelays.add(relay); + saveEnabledRelays(enabledRelays); + + populateRelays(); + dialog.dismiss(); + }); + } + + private void removeRelay(@NonNull final String relay) { + final List relays = new java.util.ArrayList<>(getRelayList()); + if (!relays.remove(relay)) { + return; + } + saveRelayList(relays); + + final Set enabledRelays = getEnabledRelays(relays); + enabledRelays.remove(relay); + saveEnabledRelays(enabledRelays); + populateRelays(); + } + + private void resetRelaysToDefault() { + saveRelayList(DEFAULT_RELAYS); + saveEnabledRelays(DEFAULT_ENABLED_RELAYS); + populateRelays(); + requestSyncIfEligible(); + } + + private void showSignInDialog() { + final View view = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_nostr_sign_in, null, false); + final TextView messageView = view.findViewById(R.id.nostr_sign_in_message); + final Button useSignerButton = view.findViewById(R.id.nostr_use_signer_button); + final TextView installAccountManagerLabel = + view.findViewById(R.id.nostr_install_account_manager_label); + final LinearLayout installAccountManagerRow = + view.findViewById(R.id.nostr_install_account_manager_row); + final Button installPrimalButton = view.findViewById(R.id.nostr_install_primal_button); + final Button installAmberButton = view.findViewById(R.id.nostr_install_amber_button); + final TextView orLabel = view.findViewById(R.id.nostr_sign_in_or_label); + final Button createAccountButton = view.findViewById(R.id.nostr_create_account_button); + final TextView onboardingNote = view.findViewById(R.id.nostr_sign_in_onboarding_note); + final View advancedToggle = view.findViewById(R.id.nostr_advanced_toggle); + final View advancedContent = view.findViewById(R.id.nostr_advanced_content); + final ImageView advancedChevron = view.findViewById(R.id.nostr_advanced_chevron); + final Button generateKeypairButton = + view.findViewById(R.id.nostr_generate_keys_button); + final ImageButton scanNsecButton = view.findViewById(R.id.nostr_scan_nsec_button); + final EditText nsecInput = view.findViewById(R.id.nostr_nsec_input); + + final boolean[] advancedVisible = {false}; + advancedContent.setVisibility(View.GONE); + advancedChevron.setImageResource(R.drawable.ic_arrow_drop_down); + advancedToggle.setOnClickListener(v -> { + advancedVisible[0] = !advancedVisible[0]; + advancedContent.setVisibility(advancedVisible[0] ? View.VISIBLE : View.GONE); + advancedChevron.setImageResource(advancedVisible[0] + ? R.drawable.ic_arrow_drop_up + : R.drawable.ic_arrow_drop_down); + }); + + final boolean hasNip55Signer = hasNip55SignerApp(); + + useSignerButton.setVisibility(View.GONE); + installAccountManagerLabel.setVisibility(View.GONE); + installAccountManagerRow.setVisibility(View.GONE); + orLabel.setVisibility(View.GONE); + createAccountButton.setVisibility(View.GONE); + onboardingNote.setVisibility(View.GONE); + messageView.setGravity(Gravity.START); + + if (hasNip55Signer) { + messageView.setText(R.string.nostr_sign_in_with_signer_message); + useSignerButton.setVisibility(View.VISIBLE); + useSignerButton.setText(buildTwoLineButtonText(R.string.nostr_log_in_with_nip55)); + useSignerButton.setOnClickListener(v -> requestNip55PublicKey()); + } else { + messageView.setText(R.string.nostr_sign_in_no_account_message); + messageView.setGravity(Gravity.CENTER_HORIZONTAL); + installAccountManagerLabel.setVisibility(View.VISIBLE); + installAccountManagerRow.setVisibility(View.VISIBLE); + installPrimalButton.setOnClickListener(v -> ShareUtils.installApp( + requireContext(), PRIMAL_PACKAGE_NAME + )); + installAmberButton.setOnClickListener(v -> ShareUtils.installApp( + requireContext(), AMBER_PACKAGE_NAME + )); + orLabel.setVisibility(View.VISIBLE); + createAccountButton.setVisibility(View.VISIBLE); + onboardingNote.setVisibility(View.VISIBLE); + createAccountButton.setOnClickListener(v -> startCreateAccountFlow()); + } + + generateKeypairButton.setOnClickListener(v -> { + try { + final NostrKeyUtils.NostrIdentity identity = NostrKeyUtils.generateIdentity(); + saveIdentity(identity); + Toast.makeText(requireContext(), + R.string.nostr_identity_generated, Toast.LENGTH_SHORT).show(); + } catch (final RuntimeException e) { + Toast.makeText(requireContext(), + R.string.nostr_identity_failed, Toast.LENGTH_LONG).show(); + } + }); + scanNsecButton.setOnClickListener(v -> launchNsecScanner()); + + signInDialog = new AlertDialog.Builder(requireContext()) + .setTitle(R.string.nostr_sign_in) + .setView(view) + .setPositiveButton(R.string.done, null) + .create(); + signInDialog.show(); + final Button doneButton = signInDialog.getButton(AlertDialog.BUTTON_POSITIVE); + doneButton.setOnClickListener(v -> { + final CharSequence nsecInputText = nsecInput.getText(); + final String enteredNsec = nsecInputText == null + ? "" + : nsecInputText.toString().trim(); + if (!TextUtils.isEmpty(enteredNsec)) { + importNsecIdentity(enteredNsec, R.string.nostr_invalid_nsec); + return; + } + + signInDialog.dismiss(); + signInDialog = null; + }); + } + + private void startCreateAccountFlow() { + final NostrKeyUtils.NostrIdentity identity; + try { + identity = NostrKeyUtils.generateIdentity(); + } catch (final RuntimeException e) { + Toast.makeText(requireContext(), + R.string.nostr_identity_failed, Toast.LENGTH_LONG).show(); + return; + } + showCreateAccountDialog(identity); + } + + private void showCreateAccountDialog(@NonNull final NostrKeyUtils.NostrIdentity identity) { + final View view = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_nostr_create_account, null, false); + final EditText nameInput = view.findViewById(R.id.nostr_profile_name_input); + final EditText displayNameInput = + view.findViewById(R.id.nostr_profile_display_name_input); + + final AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setTitle(R.string.nostr_create_account_title) + .setView(view) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.create, null) + .create(); + dialog.show(); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String name = trimmedValue(nameInput); + String displayName = trimmedValue(displayNameInput); + + if (TextUtils.isEmpty(name) && TextUtils.isEmpty(displayName)) { + Toast.makeText(requireContext(), + R.string.nostr_profile_info_required, Toast.LENGTH_LONG).show(); + return; + } + if (TextUtils.isEmpty(name)) { + name = displayName; + } + if (TextUtils.isEmpty(displayName)) { + displayName = name; + } + if (!TextUtils.isEmpty(name) && containsWhitespace(name)) { + Toast.makeText(requireContext(), + R.string.nostr_profile_name_no_spaces, Toast.LENGTH_LONG).show(); + return; + } + + final LocalProfileMetadata profileMetadata = new LocalProfileMetadata( + name, + displayName + ); + saveIdentity(identity, profileMetadata); + NostrSyncManager.publishProfileMetadata( + requireContext(), + identity.nsec, + profileMetadata.name, + profileMetadata.displayName, + null + ); + Toast.makeText(requireContext(), + R.string.nostr_identity_generated, Toast.LENGTH_SHORT).show(); + dialog.dismiss(); + }); + } + + private boolean hasNip55SignerApp() { + final Intent signerIntent = buildNip55GetPublicKeyIntent(); + return !requireContext().getPackageManager() + .queryIntentActivities(signerIntent, 0) + .isEmpty(); + } + + @NonNull + private Intent buildNip55GetPublicKeyIntent() { + final Intent signerIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(NIP55_URI)); + signerIntent.putExtra(NIP55_TYPE, NIP55_TYPE_GET_PUBLIC_KEY); + final String permissions = buildNip55SyncPermissions(); + if (!TextUtils.isEmpty(permissions)) { + signerIntent.putExtra(NIP55_PERMISSIONS, permissions); + } + return signerIntent; + } + + @Nullable + private String buildNip55SyncPermissions() { + try { + final JSONArray permissions = new JSONArray(); + permissions.put(new JSONObject() + .put(NIP55_PERMISSION_TYPE, NIP55_PERMISSION_SIGN_EVENT) + .put(NIP55_PERMISSION_KIND, NOSTR_SYNC_APP_DATA_KIND)); + permissions.put(new JSONObject() + .put(NIP55_PERMISSION_TYPE, NIP55_PERMISSION_NIP44_ENCRYPT)); + permissions.put(new JSONObject() + .put(NIP55_PERMISSION_TYPE, NIP55_PERMISSION_NIP44_DECRYPT)); + return permissions.toString(); + } catch (final JSONException e) { + return null; + } + } + + private void requestNip55PublicKey() { + requestNip55PublicKey(null); + } + + private void requestNip55PublicKey(@Nullable final String signerPackage) { + final Intent signerIntent = buildNip55GetPublicKeyIntent(); + if (!TextUtils.isEmpty(signerPackage)) { + signerIntent.setPackage(signerPackage); + } + if (signerIntent.resolveActivity(requireContext().getPackageManager()) == null) { + pendingSignerPackage = null; + Toast.makeText(requireContext(), + R.string.nostr_signer_app_not_found, Toast.LENGTH_SHORT).show(); + return; + } + pendingSignerPackage = signerPackage; + nip55RequestLauncher.launch(signerIntent); + } + + private void onNip55SignerResult( + @NonNull final androidx.activity.result.ActivityResult activityResult) { + if (activityResult.getResultCode() != Activity.RESULT_OK) { + pendingSignerPackage = null; + Toast.makeText(requireContext(), R.string.nostr_signer_request_cancelled, + Toast.LENGTH_SHORT).show(); + return; + } + + final Intent data = activityResult.getData(); + if (data == null) { + pendingSignerPackage = null; + Toast.makeText(requireContext(), R.string.nostr_signer_invalid_response, + Toast.LENGTH_LONG).show(); + return; + } + + final String signerResult = extractSignerPublicKey(data); + if (TextUtils.isEmpty(signerResult)) { + pendingSignerPackage = null; + Toast.makeText(requireContext(), R.string.nostr_signer_invalid_response, + Toast.LENGTH_LONG).show(); + return; + } + + try { + final String npub = NostrKeyUtils.toNpub(signerResult); + String signerPackage = extractNip55Value(data, NIP55_PACKAGE); + if (TextUtils.isEmpty(signerPackage)) { + signerPackage = pendingSignerPackage; + } + if (TextUtils.isEmpty(signerPackage)) { + signerPackage = resolvePreferredSignerPackage(); + } + saveSignerIdentity(npub, signerPackage); + pendingSignerPackage = null; + Toast.makeText(requireContext(), R.string.nostr_signer_identity_connected, + Toast.LENGTH_SHORT).show(); + } catch (final RuntimeException e) { + pendingSignerPackage = null; + Toast.makeText(requireContext(), R.string.nostr_signer_invalid_response, + Toast.LENGTH_LONG).show(); + } + } + + @Nullable + private String extractSignerPublicKey(@NonNull final Intent data) { + final String[] candidateKeys = { + NIP55_RESULT, + NIP55_SIGNATURE, + NIP55_PUBKEY, + NIP55_NPUB + }; + for (final String key : candidateKeys) { + final String value = extractNip55Value(data, key); + if (!TextUtils.isEmpty(value)) { + return value; + } + } + return null; + } + + @Nullable + private String resolvePreferredSignerPackage() { + final Intent signerIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(NIP55_URI)); + signerIntent.putExtra(NIP55_TYPE, NIP55_TYPE_GET_PUBLIC_KEY); + final List candidates = requireContext().getPackageManager() + .queryIntentActivities(signerIntent, 0); + if (candidates.isEmpty()) { + return null; + } + + String selectedPackage = null; + for (final android.content.pm.ResolveInfo candidate : candidates) { + if (candidate.activityInfo == null + || TextUtils.isEmpty(candidate.activityInfo.packageName)) { + continue; + } + final String packageName = candidate.activityInfo.packageName; + if (AMBER_PACKAGE_NAME.equals(packageName)) { + selectedPackage = packageName; + break; + } + if (selectedPackage == null) { + selectedPackage = packageName; + } + } + return selectedPackage; + } + + @Nullable + private String extractNip55Value(@NonNull final Intent data, @NonNull final String key) { + final String directExtra = data.getStringExtra(key); + if (!TextUtils.isEmpty(directExtra)) { + return directExtra; + } + + final Uri dataUri = data.getData(); + if (dataUri != null) { + final String queryParam = dataUri.getQueryParameter(key); + if (!TextUtils.isEmpty(queryParam)) { + return queryParam; + } + + final String resultsQuery = dataUri.getQueryParameter(NIP55_RESULTS); + if (!TextUtils.isEmpty(resultsQuery)) { + final String jsonValue = readJsonResultValue(resultsQuery, key); + if (!TextUtils.isEmpty(jsonValue)) { + return jsonValue; + } + } + } + + final String resultsExtra = data.getStringExtra(NIP55_RESULTS); + if (!TextUtils.isEmpty(resultsExtra)) { + return readJsonResultValue(resultsExtra, key); + } + return null; + } + + @Nullable + private String readJsonResultValue(@NonNull final String rawJson, @NonNull final String key) { + try { + final JSONArray array = new JSONArray(rawJson); + if (array.length() == 0) { + return null; + } + final JSONObject firstResult = array.optJSONObject(0); + if (firstResult == null) { + return null; + } + final String value = firstResult.optString(key, null); + return TextUtils.isEmpty(value) ? null : value; + } catch (final JSONException ignored) { + return null; + } + } + + private void launchNsecScanner() { + final ScanOptions options = new ScanOptions(); + options.setDesiredBarcodeFormats(ScanOptions.QR_CODE); + options.setPrompt(getString(R.string.nostr_scan_nsec_prompt)); + options.setBeepEnabled(false); + options.setCaptureActivity(PortraitCaptureActivity.class); + options.setOrientationLocked(true); + scanNsecLauncher.launch(options); + } + + private void onNsecScanResult(final ScanIntentResult result) { + if (result == null || result.getContents() == null) { + return; + } + importNsecIdentity(result.getContents(), R.string.nostr_invalid_nsec_qr); + } + + private boolean importNsecIdentity(@Nullable final String rawNsec, + final int invalidMessageResId) { + if (TextUtils.isEmpty(rawNsec)) { + return false; + } + try { + final NostrKeyUtils.NostrIdentity identity = NostrKeyUtils + .fromScannedNsec(rawNsec.trim()); + saveIdentity(identity); + Toast.makeText(requireContext(), + R.string.nostr_identity_imported, Toast.LENGTH_SHORT).show(); + return true; + } catch (final RuntimeException e) { + Toast.makeText(requireContext(), invalidMessageResId, + Toast.LENGTH_LONG).show(); + return false; + } + } + + private void saveIdentity(@NonNull final NostrKeyUtils.NostrIdentity identity) { + saveIdentity(identity, null); + } + + private void saveIdentity(@NonNull final NostrKeyUtils.NostrIdentity identity, + @Nullable final LocalProfileMetadata profileMetadata) { + final SharedPreferences.Editor editor = preferences.edit() + .putString(PREF_NOSTR_NSEC, identity.nsec) + .putString(PREF_NOSTR_NPUB, identity.npub) + .putBoolean(PREF_NOSTR_EXTERNAL_SIGNER, false) + .remove(PREF_NOSTR_SIGNER_PACKAGE); + if (profileMetadata == null) { + editor.remove(PREF_NOSTR_PROFILE_NAME) + .remove(PREF_NOSTR_PROFILE_DISPLAY_NAME) + .remove(PREF_NOSTR_PROFILE_PICTURE_URL); + } else { + putOrRemove(editor, PREF_NOSTR_PROFILE_NAME, profileMetadata.name); + putOrRemove(editor, PREF_NOSTR_PROFILE_DISPLAY_NAME, profileMetadata.displayName); + editor.remove(PREF_NOSTR_PROFILE_PICTURE_URL); + } + editor.apply(); + updateIdentityButtons(); + + if (signInDialog != null) { + signInDialog.dismiss(); + signInDialog = null; + } + requestSyncIfEligible(); + } + + private void saveSignerIdentity(@NonNull final String npub, + @Nullable final String signerPackage) { + final SharedPreferences.Editor editor = preferences.edit() + .remove(PREF_NOSTR_NSEC) + .putString(PREF_NOSTR_NPUB, npub) + .putBoolean(PREF_NOSTR_EXTERNAL_SIGNER, true) + .remove(PREF_NOSTR_PROFILE_NAME) + .remove(PREF_NOSTR_PROFILE_DISPLAY_NAME) + .remove(PREF_NOSTR_PROFILE_PICTURE_URL); + if (TextUtils.isEmpty(signerPackage)) { + editor.remove(PREF_NOSTR_SIGNER_PACKAGE); + } else { + editor.putString(PREF_NOSTR_SIGNER_PACKAGE, signerPackage); + } + editor.apply(); + updateIdentityButtons(); + + if (signInDialog != null) { + signInDialog.dismiss(); + signInDialog = null; + } + requestSyncIfEligible(); + } + + private void updateIdentityButtons() { + final String nsec = preferences.getString(PREF_NOSTR_NSEC, null); + final String npub = preferences.getString(PREF_NOSTR_NPUB, null); + final boolean hasExternalSigner = + preferences.getBoolean(PREF_NOSTR_EXTERNAL_SIGNER, false); + final boolean hasIdentity = !TextUtils.isEmpty(npub) + && (!TextUtils.isEmpty(nsec) || hasExternalSigner); + + signInButton.setVisibility(hasIdentity ? View.GONE : View.VISIBLE); + identityActionsContainer.setVisibility(hasIdentity ? View.VISIBLE : View.GONE); + } + + private void showClearIdentityDialog() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.nostr_clear_identity_title) + .setMessage(R.string.nostr_clear_identity_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.nostr_clear, (dialog, which) -> clearIdentity()) + .show(); + } + + private void clearIdentity() { + preferences.edit() + .remove(PREF_NOSTR_NSEC) + .remove(PREF_NOSTR_NPUB) + .remove(PREF_NOSTR_EXTERNAL_SIGNER) + .remove(PREF_NOSTR_SIGNER_PACKAGE) + .remove(PREF_NOSTR_PROFILE_NAME) + .remove(PREF_NOSTR_PROFILE_DISPLAY_NAME) + .remove(PREF_NOSTR_PROFILE_PICTURE_URL) + .apply(); + updateIdentityButtons(); + Toast.makeText( + requireContext(), + R.string.nostr_identity_cleared, + Toast.LENGTH_SHORT + ).show(); + } + + private void showIdentityDialog() { + final String nsec = preferences.getString(PREF_NOSTR_NSEC, null); + final String npub = preferences.getString(PREF_NOSTR_NPUB, null); + final boolean hasExternalSigner = preferences.getBoolean(PREF_NOSTR_EXTERNAL_SIGNER, false); + final boolean hasLocalNsec = !TextUtils.isEmpty(nsec); + if (TextUtils.isEmpty(npub) || (!hasLocalNsec && !hasExternalSigner)) { + return; + } + + final View view = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_nostr_identity, null, false); + + final ImageView profileImage = view.findViewById(R.id.nostr_profile_image); + final TextView npubValue = view.findViewById(R.id.nostr_npub_value); + final ImageButton npubCopy = view.findViewById(R.id.nostr_npub_copy_button); + final ImageView npubQr = view.findViewById(R.id.nostr_npub_qr); + final TextView nsecLabel = view.findViewById(R.id.nostr_nsec_label); + final LinearLayout nsecRow = view.findViewById(R.id.nostr_nsec_row); + final TextView nsecValue = view.findViewById(R.id.nostr_nsec_value); + final ImageButton nsecVisibilityToggle = + view.findViewById(R.id.nostr_nsec_visibility_button); + final ImageButton nsecCopy = view.findViewById(R.id.nostr_nsec_copy_button); + final ImageView nsecQr = view.findViewById(R.id.nostr_nsec_qr); + final TextView signerManagedMessage = + view.findViewById(R.id.nostr_signer_managed_message); + + final String profilePictureUrl = preferences.getString( + PREF_NOSTR_PROFILE_PICTURE_URL, + null + ); + if (TextUtils.isEmpty(profilePictureUrl)) { + profileImage.setImageResource(R.drawable.placeholder_person); + } else { + CoilHelper.INSTANCE.loadAvatar(profileImage, profilePictureUrl); + } + + npubValue.setText(npub); + npubCopy.setOnClickListener(v -> ShareUtils.copyToClipboard(requireContext(), npub)); + + final int qrSize = (int) (220 * getResources().getDisplayMetrics().density); + try { + npubQr.setImageBitmap(NostrKeyUtils.generateQrCode(npub, qrSize)); + if (hasLocalNsec) { + nsecQr.setImageBitmap(NostrKeyUtils.generateQrCode(nsec, qrSize)); + } + } catch (final RuntimeException e) { + Toast.makeText(requireContext(), R.string.nostr_qr_generation_failed, + Toast.LENGTH_LONG).show(); + } + + if (hasLocalNsec) { + signerManagedMessage.setVisibility(View.GONE); + nsecLabel.setVisibility(View.VISIBLE); + nsecRow.setVisibility(View.VISIBLE); + nsecQr.setVisibility(View.VISIBLE); + + final boolean[] nsecVisible = {false}; + nsecValue.setText(MASKED_NSEC); + nsecVisibilityToggle.setImageResource(R.drawable.ic_visibility_off); + nsecVisibilityToggle.setOnClickListener(v -> { + nsecVisible[0] = !nsecVisible[0]; + nsecValue.setText(nsecVisible[0] ? nsec : MASKED_NSEC); + nsecVisibilityToggle.setImageResource( + nsecVisible[0] + ? R.drawable.ic_visibility_on + : R.drawable.ic_visibility_off + ); + }); + nsecCopy.setOnClickListener(v -> ShareUtils.copyToClipboard(requireContext(), nsec)); + } else { + nsecLabel.setVisibility(View.VISIBLE); + nsecRow.setVisibility(View.GONE); + nsecQr.setVisibility(View.GONE); + signerManagedMessage.setVisibility(View.VISIBLE); + signerManagedMessage.setText(R.string.nostr_nsec_managed_by_signer); + } + + new AlertDialog.Builder(requireContext()) + .setTitle(buildIdentityDialogTitle()) + .setView(view) + .setPositiveButton(R.string.done, null) + .show(); + } + + @NonNull + private String buildIdentityDialogTitle() { + final String username = normalizeUsername( + preferences.getString(PREF_NOSTR_PROFILE_NAME, null)); + final String displayName = trimmedPreferenceValue( + preferences.getString(PREF_NOSTR_PROFILE_DISPLAY_NAME, null)); + if (!TextUtils.isEmpty(username)) { + final String usernameWithPrefix = "@" + username; + if (!TextUtils.isEmpty(displayName)) { + return displayName + " (" + usernameWithPrefix + ")"; + } + return getString(R.string.nostr_identity_title_with_username, usernameWithPrefix); + } + return getString(R.string.nostr_identity_title); + } + + private void requestSyncIfEligible() { + final boolean syncWatchHistory = preferences.getBoolean( + PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED, false + ); + final boolean syncSubscriptions = preferences.getBoolean( + PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED, false + ); + if (!syncWatchHistory && !syncSubscriptions) { + return; + } + NostrSyncManager.requestSync(requireContext()); + } + + @NonNull + private CharSequence buildTwoLineButtonText(@StringRes final int textResId) { + final String rawText = getString(textResId); + final int lineBreakIndex = rawText.indexOf('\n'); + if (lineBreakIndex < 0 || lineBreakIndex >= rawText.length() - 1) { + return rawText; + } + + final SpannableString spannable = new SpannableString(rawText); + spannable.setSpan( + new RelativeSizeSpan(0.82f), + lineBreakIndex + 1, + rawText.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + return spannable; + } + + @Nullable + private static String trimmedValue(@NonNull final EditText input) { + final CharSequence value = input.getText(); + if (value == null) { + return null; + } + final String trimmed = value.toString().trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static void putOrRemove(@NonNull final SharedPreferences.Editor editor, + @NonNull final String key, + @Nullable final String value) { + if (TextUtils.isEmpty(value)) { + editor.remove(key); + return; + } + editor.putString(key, value); + } + + private static boolean containsWhitespace(@NonNull final String text) { + for (int i = 0; i < text.length(); i++) { + if (Character.isWhitespace(text.charAt(i))) { + return true; + } + } + return false; + } + + @Nullable + private static String normalizeUsername(@Nullable final String username) { + final String normalized = trimmedPreferenceValue(username); + if (TextUtils.isEmpty(normalized)) { + return null; + } + if (normalized.startsWith("@")) { + final String withoutPrefix = normalized.substring(1).trim(); + return withoutPrefix.isEmpty() ? null : withoutPrefix; + } + return normalized; + } + + @Nullable + private static String trimmedPreferenceValue(@Nullable final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static final class LocalProfileMetadata { + @Nullable + private final String name; + @Nullable + private final String displayName; + + LocalProfileMetadata(@Nullable final String name, + @Nullable final String displayName) { + this.name = name; + this.displayName = displayName; + } + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java new file mode 100644 index 00000000000..3b0291a179f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java @@ -0,0 +1,1867 @@ +package org.schabi.newpipe.local.nostr; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.feed.model.FeedGroupEntity; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.database.subscription.NotificationMode; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.local.subscription.FeedGroupIcon; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +public final class NostrSyncManager { + private static final String TAG = "NostrSyncManager"; + + private static final String PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED = + "nostr_sync_watch_history_enabled"; + private static final String PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED = + "nostr_sync_subscriptions_enabled"; + private static final String PREF_NOSTR_NSEC = "nostr_nsec"; + private static final String PREF_NOSTR_NPUB = "nostr_npub"; + private static final String PREF_NOSTR_EXTERNAL_SIGNER = "nostr_external_signer"; + private static final String PREF_NOSTR_SIGNER_PACKAGE = "nostr_signer_package"; + private static final String PREF_NOSTR_RELAYS = "nostr_relays"; + private static final String PREF_NOSTR_ENABLED_RELAYS = "nostr_enabled_relays"; + private static final String PREF_NOSTR_SYNC_DEVICE_ID = "nostr_sync_device_id"; + private static final String PREF_NOSTR_LAST_CONNECTED_RELAYS = + "nostr_sync_last_connected_relays"; + private static final String PREF_NOSTR_LAST_TOTAL_RELAYS = "nostr_sync_last_total_relays"; + + private static final List DEFAULT_RELAYS = Arrays.asList( + "wss://relay.primal.net", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://nostr.oxtr.dev", + "wss://nos.lol", + "wss://nostr.bitcoiner.social", + "wss://nostr.semisol.dev", + "wss://shu01.shugur.net", + "wss://shu02.shugur.net", + "wss://shu03.shugur.net", + "wss://shu04.shugur.net", + "wss://shu05.shugur.net" + ); + private static final Set DEFAULT_ENABLED_RELAYS = new HashSet<>(Arrays.asList( + "wss://relay.primal.net", + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://nostr.oxtr.dev", + "wss://nos.lol", + "wss://nostr.bitcoiner.social", + "wss://nostr.semisol.dev" + )); + private static final String AMBER_PACKAGE_NAME = "com.greenart7c3.nostrsigner"; + private static final String NIP55_URI = "nostrsigner:"; + private static final String NIP55_TYPE = "type"; + private static final String NIP55_TYPE_GET_PUBLIC_KEY = "get_public_key"; + private static final String CATEGORY_WATCH_HISTORY = "watch_history"; + private static final String CATEGORY_SUBSCRIPTIONS = "subscriptions"; + private static final String D_TAG_HISTORY_PREFIX = "newpipe-sync-watch-history:"; + private static final String D_TAG_SUBSCRIPTIONS_PREFIX = "newpipe-sync-subscriptions:"; + private static final int KIND_PROFILE_METADATA = 0; + private static final int KIND_APP_DATA = 30078; + private static final int RELAY_CONNECT_TIMEOUT_MS = 6000; + private static final int RELAY_EOSE_TIMEOUT_MS = 7000; + private static final int RELAY_PUBLISH_TIMEOUT_MS = 4500; + private static final int MAX_HISTORY_RECORDS_PER_SNAPSHOT = 150; + private static final int MAX_SUBSCRIPTIONS_PER_SNAPSHOT = 500; + private static final int MAX_CATEGORY_DATA_BYTES = 28 * 1024; + + private static final AtomicBoolean SYNC_RUNNING = new AtomicBoolean(false); + private static final OkHttpClient WS_CLIENT = new OkHttpClient.Builder() + .retryOnConnectionFailure(true) + .build(); + + private NostrSyncManager() { + } + + public static boolean isSyncRunning() { + return SYNC_RUNNING.get(); + } + + @NonNull + public static RelayStatus getRelayStatus(@NonNull final Context context) { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + final int connected = Math.max(0, + preferences.getInt(PREF_NOSTR_LAST_CONNECTED_RELAYS, 0)); + + final Set enabledRelays = getEnabledRelays(preferences); + final int storedTotal = preferences.getInt(PREF_NOSTR_LAST_TOTAL_RELAYS, 0); + final int total = Math.max(enabledRelays.size(), storedTotal); + return new RelayStatus(Math.min(connected, total), total); + } + + public static void requestSync(@NonNull final Context context) { + if (!SYNC_RUNNING.compareAndSet(false, true)) { + return; + } + + Completable.fromAction(() -> syncBlocking(context.getApplicationContext())) + .subscribeOn(Schedulers.io()) + .doFinally(() -> SYNC_RUNNING.set(false)) + .subscribe( + () -> { + }, + throwable -> Log.w(TAG, "Sync failed", throwable) + ); + } + + public static void publishProfileMetadata(@NonNull final Context context, + @NonNull final String nsec, + @Nullable final String name, + @Nullable final String displayName, + @Nullable final String pictureUrl) { + final String normalizedNsec = trimToNull(nsec); + final String normalizedName = trimToNull(name); + final String normalizedDisplayName = trimToNull(displayName); + final String normalizedPictureUrl = trimToNull(pictureUrl); + if (TextUtils.isEmpty(normalizedNsec)) { + return; + } + if (TextUtils.isEmpty(normalizedName) + && TextUtils.isEmpty(normalizedDisplayName) + && TextUtils.isEmpty(normalizedPictureUrl)) { + return; + } + + Completable.fromAction(() -> publishProfileMetadataBlocking( + context.getApplicationContext(), + normalizedNsec, + normalizedName, + normalizedDisplayName, + normalizedPictureUrl + )) + .subscribeOn(Schedulers.io()) + .subscribe( + () -> { + }, + throwable -> Log.w(TAG, "Profile metadata publish failed", throwable) + ); + } + + private static void syncBlocking(@NonNull final Context context) { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); + final boolean syncHistory = preferences.getBoolean( + PREF_NOSTR_SYNC_WATCH_HISTORY_ENABLED, false + ); + final boolean syncSubscriptions = preferences.getBoolean( + PREF_NOSTR_SYNC_SUBSCRIPTIONS_ENABLED, false + ); + if (!syncHistory && !syncSubscriptions) { + return; + } + + final String npub = preferences.getString(PREF_NOSTR_NPUB, null); + if (TextUtils.isEmpty(npub)) { + Log.d(TAG, "Skipping sync: missing npub"); + return; + } + + final String nsec = preferences.getString(PREF_NOSTR_NSEC, null); + final String localNsec = TextUtils.isEmpty(nsec) ? null : nsec; + final boolean hasExternalSigner = preferences.getBoolean(PREF_NOSTR_EXTERNAL_SIGNER, false); + if (localNsec == null && !hasExternalSigner) { + Log.d(TAG, "Skipping sync: missing local nsec and no external signer"); + return; + } + + final String pubKeyHex; + try { + pubKeyHex = localNsec != null + ? NostrKeyUtils.derivePublicKeyHexFromNsec(localNsec) + : NostrKeyUtils.toPublicKeyHex(npub); + } catch (final RuntimeException e) { + Log.w(TAG, "Skipping sync: invalid nostr identity", e); + return; + } + + final String signerPackage = localNsec == null + ? resolveSignerPackage(context, preferences) + : null; + if (localNsec == null && TextUtils.isEmpty(signerPackage)) { + Log.d(TAG, "Skipping sync: external signer package unavailable"); + return; + } + + final String deviceId = getOrCreateDeviceId(preferences); + final Set relays = getEnabledRelays(preferences); + updateRelayStatus(preferences, 0, relays.size()); + Log.d(TAG, "Starting sync. relays=" + relays.size() + + " history=" + syncHistory + " subscriptions=" + syncSubscriptions); + final List relayEvents = fetchSyncEvents(relays, pubKeyHex); + Log.d(TAG, "Fetched " + relayEvents.size() + " candidate events"); + + final AppDatabase database = NewPipeDatabase.getInstance(context); + + if (syncHistory) { + final Map localHistory = readLocalHistory(database); + final Map remoteHistory = + readHistoryFromEvents( + relayEvents, + context, + localNsec, + signerPackage, + pubKeyHex + ); + final Map mergedHistory = + mergeHistoryMaps( + remoteHistory, + localHistory + ); + Log.d(TAG, "History merge local=" + localHistory.size() + + " remote=" + remoteHistory.size() + + " merged=" + mergedHistory.size()); + applyHistoryToDatabase(database, mergedHistory); + final int acceptedRelays = publishCategorySnapshot( + context, + relays, + localNsec, + signerPackage, + pubKeyHex, + CATEGORY_WATCH_HISTORY, + D_TAG_HISTORY_PREFIX + deviceId, + historyToJson(readLocalHistory(database)) + ); + updateRelayStatus(preferences, acceptedRelays, relays.size()); + } + + if (syncSubscriptions) { + final Map localSubscriptions = + readLocalSubscriptions(database); + final Map remoteSubscriptions = + readSubscriptionsFromEvents( + relayEvents, + context, + localNsec, + signerPackage, + pubKeyHex + ); + final Map mergedSubscriptions = + mergeSubscriptionMaps( + remoteSubscriptions, + localSubscriptions + ); + Log.d(TAG, "Subscription merge local=" + localSubscriptions.size() + + " remote=" + remoteSubscriptions.size() + + " merged=" + mergedSubscriptions.size()); + applySubscriptionsToDatabase(database, mergedSubscriptions); + final int acceptedRelays = publishCategorySnapshot( + context, + relays, + localNsec, + signerPackage, + pubKeyHex, + CATEGORY_SUBSCRIPTIONS, + D_TAG_SUBSCRIPTIONS_PREFIX + deviceId, + subscriptionsToJson(readLocalSubscriptions(database)) + ); + updateRelayStatus(preferences, acceptedRelays, relays.size()); + } + } + + private static void publishProfileMetadataBlocking(@NonNull final Context context, + @NonNull final String nsec, + @Nullable final String name, + @Nullable final String displayName, + @Nullable final String pictureUrl) { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); + final Set relays = getEnabledRelays(preferences); + if (relays.isEmpty()) { + Log.d(TAG, "Skipping profile metadata publish: no enabled relays"); + return; + } + + final String pubKeyHex; + try { + pubKeyHex = NostrKeyUtils.derivePublicKeyHexFromNsec(nsec); + } catch (final RuntimeException e) { + Log.w(TAG, "Skipping profile metadata publish: invalid nsec", e); + return; + } + + final JSONObject metadata = new JSONObject(); + try { + if (!TextUtils.isEmpty(name)) { + metadata.put("name", name); + } + if (!TextUtils.isEmpty(displayName)) { + metadata.put("display_name", displayName); + } + if (!TextUtils.isEmpty(pictureUrl)) { + metadata.put("picture", pictureUrl); + } + } catch (final JSONException e) { + Log.w(TAG, "Skipping profile metadata publish: metadata serialization error", e); + return; + } + + final String content = metadata.toString(); + final long now = Instant.now().getEpochSecond(); + final JSONArray tags = new JSONArray(); + final JSONObject unsignedEvent = new JSONObject(); + try { + unsignedEvent.put("pubkey", pubKeyHex); + unsignedEvent.put("created_at", now); + unsignedEvent.put("kind", KIND_PROFILE_METADATA); + unsignedEvent.put("tags", tags); + unsignedEvent.put("content", content); + } catch (final JSONException e) { + return; + } + + final String serialized = serializeEventForId( + pubKeyHex, + now, + KIND_PROFILE_METADATA, + tags, + content + ); + final byte[] eventHash = sha256(serialized.getBytes(StandardCharsets.UTF_8)); + final String eventId = bytesToHex(eventHash); + final JSONObject signedEvent; + try { + final String signature = NostrKeyUtils.signEventId(nsec, eventHash); + signedEvent = new JSONObject(unsignedEvent.toString()) + .put("id", eventId) + .put("sig", signature); + } catch (final RuntimeException | JSONException e) { + Log.w(TAG, "Skipping profile metadata publish: signing failed", e); + return; + } + + final String eventMessage = new JSONArray().put("EVENT").put(signedEvent).toString(); + int acceptedRelays = 0; + for (final String relay : relays) { + if (publishToRelay(relay, eventMessage, eventId)) { + acceptedRelays++; + } + } + Log.d(TAG, "Published profile metadata to " + + acceptedRelays + "/" + relays.size() + " relays"); + } + + @NonNull + private static String getOrCreateDeviceId(@NonNull final SharedPreferences preferences) { + final String existing = preferences.getString(PREF_NOSTR_SYNC_DEVICE_ID, null); + if (!TextUtils.isEmpty(existing)) { + return existing; + } + + final String created = UUID.randomUUID().toString(); + preferences.edit().putString(PREF_NOSTR_SYNC_DEVICE_ID, created).apply(); + return created; + } + + @NonNull + private static Set getEnabledRelays(@NonNull final SharedPreferences preferences) { + final Set configuredRelays = new HashSet<>(getConfiguredRelays(preferences)); + final Set stored = preferences.getStringSet(PREF_NOSTR_ENABLED_RELAYS, null); + if (stored != null) { + final Set filtered = new HashSet<>(stored); + filtered.retainAll(configuredRelays); + return filtered; + } + final Set defaults = new HashSet<>(DEFAULT_ENABLED_RELAYS); + defaults.retainAll(configuredRelays); + return defaults; + } + + @NonNull + private static List getConfiguredRelays(@NonNull final SharedPreferences preferences) { + final String raw = preferences.getString(PREF_NOSTR_RELAYS, null); + if (TextUtils.isEmpty(raw)) { + return DEFAULT_RELAYS; + } + + final List relays = new ArrayList<>(); + final Set seen = new HashSet<>(); + try { + final JSONArray jsonArray = new JSONArray(raw); + for (int i = 0; i < jsonArray.length(); i++) { + final String relay = jsonArray.optString(i, "").trim(); + if (TextUtils.isEmpty(relay) || !seen.add(relay)) { + continue; + } + relays.add(relay); + } + } catch (final JSONException ignored) { + return DEFAULT_RELAYS; + } + return relays; + } + + @Nullable + private static String resolveSignerPackage(@NonNull final Context context, + @NonNull final SharedPreferences preferences) { + final String storedPackage = preferences.getString(PREF_NOSTR_SIGNER_PACKAGE, null); + if (!TextUtils.isEmpty(storedPackage)) { + return storedPackage; + } + + final Intent signerIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(NIP55_URI)); + signerIntent.putExtra(NIP55_TYPE, NIP55_TYPE_GET_PUBLIC_KEY); + final List candidates = context.getPackageManager() + .queryIntentActivities(signerIntent, 0); + if (candidates.isEmpty()) { + return null; + } + + String selectedPackage = null; + for (final ResolveInfo candidate : candidates) { + if (candidate.activityInfo == null + || TextUtils.isEmpty(candidate.activityInfo.packageName)) { + continue; + } + final String packageName = candidate.activityInfo.packageName; + if (AMBER_PACKAGE_NAME.equals(packageName)) { + selectedPackage = packageName; + break; + } + if (selectedPackage == null) { + selectedPackage = packageName; + } + } + + if (!TextUtils.isEmpty(selectedPackage)) { + preferences.edit().putString(PREF_NOSTR_SIGNER_PACKAGE, selectedPackage).apply(); + } + return selectedPackage; + } + + private static void updateRelayStatus(@NonNull final SharedPreferences preferences, + final int connectedRelays, + final int totalRelays) { + preferences.edit() + .putInt(PREF_NOSTR_LAST_CONNECTED_RELAYS, Math.max(0, connectedRelays)) + .putInt(PREF_NOSTR_LAST_TOTAL_RELAYS, Math.max(0, totalRelays)) + .apply(); + } + + @NonNull + private static List fetchSyncEvents(@NonNull final Set relays, + @NonNull final String pubKeyHex) { + final Map dedupById = new LinkedHashMap<>(); + final JSONObject filter = new JSONObject(); + try { + filter.put("authors", new JSONArray().put(pubKeyHex)); + filter.put("kinds", new JSONArray().put(KIND_APP_DATA)); + filter.put("limit", 1000); + } catch (final JSONException e) { + return new ArrayList<>(); + } + + for (final String relay : relays) { + final List eventsFromRelay = fetchFromRelay(relay, filter); + for (final JSONObject event : eventsFromRelay) { + final String id = event.optString("id", ""); + if (!TextUtils.isEmpty(id)) { + dedupById.put(id, event); + } + } + } + return new ArrayList<>(dedupById.values()); + } + + @NonNull + private static List fetchFromRelay(@NonNull final String relayUrl, + @NonNull final JSONObject filter) { + final List events = Collections.synchronizedList(new ArrayList<>()); + final CountDownLatch openLatch = new CountDownLatch(1); + final CountDownLatch eoseLatch = new CountDownLatch(1); + final String subscriptionId = "np-sync-" + System.nanoTime(); + final String reqMessage; + + try { + reqMessage = new JSONArray() + .put("REQ") + .put(subscriptionId) + .put(filter) + .toString(); + } catch (final RuntimeException e) { + return events; + } + + final Request request = new Request.Builder().url(relayUrl).build(); + final WebSocket webSocket = WS_CLIENT.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(@NonNull final WebSocket webSocket, + @NonNull final okhttp3.Response response) { + openLatch.countDown(); + } + + @Override + public void onMessage(@NonNull final WebSocket webSocket, + @NonNull final String text) { + handleRelayMessage(text, subscriptionId, events, eoseLatch); + } + + @Override + public void onFailure(@NonNull final WebSocket webSocket, + @NonNull final Throwable t, + @Nullable final okhttp3.Response response) { + openLatch.countDown(); + eoseLatch.countDown(); + } + + @Override + public void onClosed(@NonNull final WebSocket webSocket, final int code, + @NonNull final String reason) { + eoseLatch.countDown(); + } + }); + + try { + if (!openLatch.await(RELAY_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + webSocket.cancel(); + return events; + } + + webSocket.send(reqMessage); + eoseLatch.await(RELAY_EOSE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + webSocket.send(new JSONArray().put("CLOSE").put(subscriptionId).toString()); + webSocket.close(1000, "done"); + } catch (final Exception e) { + webSocket.cancel(); + } + return events; + } + + private static void handleRelayMessage(@NonNull final String message, + @NonNull final String subscriptionId, + @NonNull final List sink, + @NonNull final CountDownLatch eoseLatch) { + try { + final JSONArray parsed = new JSONArray(message); + if (parsed.length() < 2) { + return; + } + final String type = parsed.optString(0, ""); + if ("EOSE".equals(type) && subscriptionId.equals(parsed.optString(1, ""))) { + eoseLatch.countDown(); + return; + } + + if (!"EVENT".equals(type) || parsed.length() < 3) { + return; + } + if (!subscriptionId.equals(parsed.optString(1, ""))) { + return; + } + final JSONObject event = parsed.optJSONObject(2); + if (event != null) { + sink.add(event); + } + } catch (final JSONException ignored) { + // Ignore malformed relay frames. + } + } + + @NonNull + private static Map readHistoryFromEvents( + @NonNull final List events, + @NonNull final Context context, + @Nullable final String nsec, + @Nullable final String signerPackage, + @NonNull final String currentUserPubKeyHex) { + final Map merged = new HashMap<>(); + int decryptedPayloads = 0; + for (final JSONObject event : events) { + if (event.optInt("kind", -1) != KIND_APP_DATA) { + continue; + } + final String dTag = getDTag(event); + if (!dTag.startsWith(D_TAG_HISTORY_PREFIX)) { + continue; + } + + final JSONObject payload = decryptPayload( + event, + context, + nsec, + signerPackage, + currentUserPubKeyHex + ); + if (payload == null || !CATEGORY_WATCH_HISTORY.equals( + payload.optString("category", "") + )) { + continue; + } + decryptedPayloads++; + + final JSONObject data = payload.optJSONObject("data"); + if (data == null) { + continue; + } + final Iterator keyIterator = data.keys(); + while (keyIterator.hasNext()) { + final String key = keyIterator.next(); + final JSONObject item = data.optJSONObject(key); + if (item == null) { + continue; + } + final HistoryRecord parsed = HistoryRecord.fromJson(item); + if (parsed == null) { + continue; + } + mergedHistoryRecord(merged, key, parsed); + } + } + Log.d(TAG, "Decoded history payloads=" + decryptedPayloads + + " records=" + merged.size()); + return merged; + } + + @NonNull + private static Map readSubscriptionsFromEvents( + @NonNull final List events, + @NonNull final Context context, + @Nullable final String nsec, + @Nullable final String signerPackage, + @NonNull final String currentUserPubKeyHex) { + final Map merged = new HashMap<>(); + int decryptedPayloads = 0; + for (final JSONObject event : events) { + if (event.optInt("kind", -1) != KIND_APP_DATA) { + continue; + } + final String dTag = getDTag(event); + if (!dTag.startsWith(D_TAG_SUBSCRIPTIONS_PREFIX)) { + continue; + } + + final JSONObject payload = decryptPayload( + event, + context, + nsec, + signerPackage, + currentUserPubKeyHex + ); + if (payload == null || !CATEGORY_SUBSCRIPTIONS.equals( + payload.optString("category", "") + )) { + continue; + } + decryptedPayloads++; + + final JSONObject data = payload.optJSONObject("data"); + if (data == null) { + continue; + } + final Iterator keyIterator = data.keys(); + while (keyIterator.hasNext()) { + final String key = keyIterator.next(); + final JSONObject item = data.optJSONObject(key); + if (item == null) { + continue; + } + final SubscriptionRecord parsed = SubscriptionRecord.fromJson(item); + if (parsed == null) { + continue; + } + mergedSubscriptionRecord(merged, key, parsed); + } + } + Log.d(TAG, "Decoded subscriptions payloads=" + decryptedPayloads + + " records=" + merged.size()); + return merged; + } + + @Nullable + private static JSONObject decryptPayload(@NonNull final JSONObject event, + @NonNull final Context context, + @Nullable final String nsec, + @Nullable final String signerPackage, + @NonNull final String currentUserPubKeyHex) { + try { + final String senderPubKey = event.optString("pubkey", null); + final String content = event.optString("content", null); + if (TextUtils.isEmpty(senderPubKey) || TextUtils.isEmpty(content)) { + return null; + } + final String encryptedPayload = extractEncryptedContent(content); + final String plain; + if (!TextUtils.isEmpty(nsec)) { + plain = NostrKeyUtils.decryptNip44( + nsec, + senderPubKey, + encryptedPayload + ); + } else if (!TextUtils.isEmpty(signerPackage)) { + plain = Nip55SignerClient.nip44Decrypt( + context, + signerPackage, + encryptedPayload, + senderPubKey, + currentUserPubKeyHex + ); + } else { + return null; + } + if (TextUtils.isEmpty(plain)) { + return null; + } + return new JSONObject(plain); + } catch (final RuntimeException | JSONException e) { + return null; + } + } + + @NonNull + private static String extractEncryptedContent(@NonNull final String eventContent) + throws JSONException { + final String trimmed = eventContent.trim(); + if (!trimmed.startsWith("{")) { + // Backward compatibility for older payloads stored as raw NIP-44 base64 text. + return eventContent; + } + + final JSONObject wrapped = new JSONObject(trimmed); + final Object dataField = wrapped.opt("data"); + if (dataField instanceof JSONObject) { + final JSONObject dataObject = (JSONObject) dataField; + final String dataCiphertext = dataObject.optString("ciphertext", null); + if (!TextUtils.isEmpty(dataCiphertext)) { + return dataCiphertext; + } + final String dataEncrypted = dataObject.optString("encrypted", null); + if (!TextUtils.isEmpty(dataEncrypted)) { + return dataEncrypted; + } + } else if (dataField instanceof String) { + final String dataString = (String) dataField; + if (!TextUtils.isEmpty(dataString)) { + return dataString; + } + } + + final String ciphertext = wrapped.optString("ciphertext", null); + if (!TextUtils.isEmpty(ciphertext)) { + return ciphertext; + } + final String encrypted = wrapped.optString("encrypted", null); + if (!TextUtils.isEmpty(encrypted)) { + return encrypted; + } + throw new JSONException("Missing encrypted payload"); + } + + @NonNull + private static String getDTag(@NonNull final JSONObject event) { + final JSONArray tags = event.optJSONArray("tags"); + if (tags == null) { + return ""; + } + for (int i = 0; i < tags.length(); i++) { + final JSONArray tag = tags.optJSONArray(i); + if (tag == null || tag.length() < 2) { + continue; + } + if ("d".equals(tag.optString(0, ""))) { + return tag.optString(1, ""); + } + } + return ""; + } + + @NonNull + private static Map readLocalHistory( + @NonNull final AppDatabase database) { + final Map output = new HashMap<>(); + final Map progressByStreamId = new HashMap<>(); + for (final StreamStateEntity streamState : database.streamStateDAO().getAllBlocking()) { + progressByStreamId.put(streamState.getStreamUid(), streamState.getProgressMillis()); + } + + final List historyEntries = database.streamHistoryDAO() + .getHistorySortedByIdBlocking(); + for (final StreamHistoryEntry entry : historyEntries) { + final StreamEntity stream = entry.getStreamEntity(); + final Long progressMillis = progressByStreamId.get(entry.getStreamId()); + final HistoryRecord record = HistoryRecord.fromEntry( + entry, + progressMillis == null ? -1 : progressMillis + ); + output.put(composeKey(stream.getServiceId(), stream.getUrl()), record); + } + return output; + } + + @NonNull + private static Map readLocalSubscriptions( + @NonNull final AppDatabase database) { + final Map output = new HashMap<>(); + final Map keyBySubscriptionId = new HashMap<>(); + final List subscriptions = database.subscriptionDAO() + .getAllBlocking(); + for (final SubscriptionEntity entity : subscriptions) { + final String url = entity.getUrl(); + if (TextUtils.isEmpty(url)) { + continue; + } + final int serviceId = entity.getServiceId(); + final SubscriptionRecord record = SubscriptionRecord.fromEntity(entity); + final String key = composeKey(serviceId, url); + output.put(key, record); + keyBySubscriptionId.put(entity.getUid(), key); + } + + final List groups = database.feedGroupDAO() + .getAllBlocking(); + for (final FeedGroupEntity group : groups) { + final String groupName = group.getName(); + if (TextUtils.isEmpty(groupName)) { + continue; + } + + final int iconId = group.getIcon() == null + ? FeedGroupIcon.ALL.getId() + : group.getIcon().getId(); + final List subscriptionIds = database.feedGroupDAO() + .getSubscriptionIdsForBlocking(group.getUid()); + for (final Long subscriptionId : subscriptionIds) { + if (subscriptionId == null) { + continue; + } + final String key = keyBySubscriptionId.get(subscriptionId); + if (TextUtils.isEmpty(key)) { + continue; + } + final SubscriptionRecord record = output.get(key); + if (record != null) { + record.mergeGroup(new GroupRecord(groupName, iconId)); + } + } + } + return output; + } + + @NonNull + private static Map mergeHistoryMaps( + @NonNull final Map remote, + @NonNull final Map local) { + final Map merged = new HashMap<>(remote); + for (final Map.Entry entry : local.entrySet()) { + mergedHistoryRecord(merged, entry.getKey(), entry.getValue()); + } + return merged; + } + + @NonNull + private static Map mergeSubscriptionMaps( + @NonNull final Map remote, + @NonNull final Map local) { + final Map merged = new HashMap<>(remote); + for (final Map.Entry entry : local.entrySet()) { + mergedSubscriptionRecord(merged, entry.getKey(), entry.getValue()); + } + return merged; + } + + private static void mergedHistoryRecord(@NonNull final Map sink, + @NonNull final String key, + @NonNull final HistoryRecord incoming) { + final HistoryRecord existing = sink.get(key); + if (existing == null) { + sink.put(key, incoming); + return; + } + final boolean incomingIsNewer = incoming.accessTs > existing.accessTs; + existing.repeatCount = Math.max(existing.repeatCount, incoming.repeatCount); + if (incomingIsNewer) { + existing.accessTs = incoming.accessTs; + existing.title = nonEmpty(incoming.title, existing.title); + existing.streamType = nonEmpty(incoming.streamType, existing.streamType); + existing.duration = incoming.duration > 0 ? incoming.duration : existing.duration; + existing.uploader = nonEmpty(incoming.uploader, existing.uploader); + existing.uploaderUrl = nonEmpty(incoming.uploaderUrl, existing.uploaderUrl); + existing.thumbnailUrl = nonEmpty(incoming.thumbnailUrl, existing.thumbnailUrl); + existing.progressMillis = incoming.progressMillis >= 0 + ? incoming.progressMillis + : existing.progressMillis; + } else { + existing.title = nonEmpty(existing.title, incoming.title); + existing.streamType = nonEmpty(existing.streamType, incoming.streamType); + existing.duration = existing.duration > 0 ? existing.duration : incoming.duration; + existing.uploader = nonEmpty(existing.uploader, incoming.uploader); + existing.uploaderUrl = nonEmpty(existing.uploaderUrl, incoming.uploaderUrl); + existing.thumbnailUrl = nonEmpty(existing.thumbnailUrl, incoming.thumbnailUrl); + if (incoming.accessTs == existing.accessTs) { + existing.progressMillis = Math.max( + existing.progressMillis, + incoming.progressMillis + ); + } else if (existing.progressMillis < 0 && incoming.progressMillis >= 0) { + existing.progressMillis = incoming.progressMillis; + } + } + } + + private static void mergedSubscriptionRecord( + @NonNull final Map sink, + @NonNull final String key, + @NonNull final SubscriptionRecord incoming) { + final SubscriptionRecord existing = sink.get(key); + if (existing == null) { + sink.put(key, incoming); + return; + } + existing.name = nonEmpty(existing.name, incoming.name); + existing.avatarUrl = nonEmpty(existing.avatarUrl, incoming.avatarUrl); + existing.description = nonEmpty(existing.description, incoming.description); + if (existing.subscriberCount == null || existing.subscriberCount < 0) { + existing.subscriberCount = incoming.subscriberCount; + } else if (incoming.subscriberCount != null && incoming.subscriberCount > 0) { + existing.subscriberCount = Math.max(existing.subscriberCount, incoming.subscriberCount); + } + existing.mergeGroupsFrom(incoming); + } + + private static void applyHistoryToDatabase(@NonNull final AppDatabase database, + @NonNull final Map history) { + database.runInTransaction(() -> { + for (final HistoryRecord record : history.values()) { + final StreamEntity streamEntity = record.toStreamEntity(); + final long streamUid = database.streamDAO().upsert(streamEntity); + final StreamHistoryEntity latestEntry = database.streamHistoryDAO() + .getLatestEntry(streamUid); + if (record.progressMillis >= 0) { + database.streamStateDAO().upsert(new StreamStateEntity( + streamUid, + record.progressMillis + )); + } + + final OffsetDateTime accessDate = OffsetDateTime.ofInstant( + Instant.ofEpochSecond(record.accessTs), + ZoneOffset.UTC + ); + if (latestEntry == null) { + database.streamHistoryDAO().insert(new StreamHistoryEntity( + streamUid, + accessDate, + Math.max(0, record.repeatCount) + )); + continue; + } + + final OffsetDateTime mergedAccessDate = + latestEntry.getAccessDate().isAfter(accessDate) + ? latestEntry.getAccessDate() + : accessDate; + final long mergedRepeatCount = Math.max( + latestEntry.getRepeatCount(), + record.repeatCount + ); + + if (latestEntry.getAccessDate().isEqual(mergedAccessDate) + && latestEntry.getRepeatCount() == mergedRepeatCount) { + continue; + } + + database.streamHistoryDAO().delete(latestEntry); + database.streamHistoryDAO().insert(new StreamHistoryEntity( + streamUid, + mergedAccessDate, + mergedRepeatCount + )); + } + }); + } + + private static void applySubscriptionsToDatabase(@NonNull final AppDatabase database, + @NonNull final Map subscriptions) { + database.runInTransaction(() -> { + final Map existing = new HashMap<>(); + final Map subscriptionIdByKey = new HashMap<>(); + final List all = database.subscriptionDAO() + .getAllBlocking(); + for (final SubscriptionEntity entity : all) { + if (!TextUtils.isEmpty(entity.getUrl())) { + final String key = composeKey(entity.getServiceId(), entity.getUrl()); + existing.put(key, entity); + subscriptionIdByKey.put(key, entity.getUid()); + } + } + + for (final Map.Entry entry : subscriptions.entrySet()) { + final SubscriptionRecord remote = entry.getValue(); + final SubscriptionEntity local = existing.get(entry.getKey()); + if (local == null) { + final SubscriptionEntity created = remote.toEntity(); + final long insertedId = database.subscriptionDAO().insert(created); + if (insertedId > 0) { + created.setUid(insertedId); + } + existing.put(entry.getKey(), created); + subscriptionIdByKey.put(entry.getKey(), created.getUid()); + } else { + final boolean changed = remote.applyTo(local); + if (changed) { + database.subscriptionDAO().update(local); + } + subscriptionIdByKey.put(entry.getKey(), local.getUid()); + } + } + + final Map mergedGroupsByName = new HashMap<>(); + final Map> membershipByGroupName = new HashMap<>(); + for (final Map.Entry entry : subscriptions.entrySet()) { + final SubscriptionRecord record = entry.getValue(); + final Long subscriptionId = subscriptionIdByKey.get(entry.getKey()); + for (final GroupRecord group : record.groups.values()) { + if (TextUtils.isEmpty(group.name)) { + continue; + } + final GroupRecord existingGroup = mergedGroupsByName.get(group.name); + if (existingGroup == null) { + mergedGroupsByName.put( + group.name, + new GroupRecord(group.name, group.iconId) + ); + } else if (existingGroup.iconId == FeedGroupIcon.ALL.getId() + && group.iconId != FeedGroupIcon.ALL.getId()) { + existingGroup.iconId = group.iconId; + } + + if (subscriptionId != null && subscriptionId > 0) { + membershipByGroupName + .computeIfAbsent(group.name, ignored -> new HashSet<>()) + .add(subscriptionId); + } + } + } + + if (mergedGroupsByName.isEmpty()) { + return; + } + + final Map localGroupsByName = new HashMap<>(); + final List localGroups = database.feedGroupDAO() + .getAllBlocking(); + for (final FeedGroupEntity group : localGroups) { + if (!TextUtils.isEmpty(group.getName())) { + localGroupsByName.put(group.getName(), group); + } + } + + final Map groupIdsByName = new HashMap<>(); + for (final GroupRecord remoteGroup : mergedGroupsByName.values()) { + if (TextUtils.isEmpty(remoteGroup.name)) { + continue; + } + + final FeedGroupEntity localGroup = localGroupsByName.get(remoteGroup.name); + if (localGroup == null) { + final FeedGroupEntity created = new FeedGroupEntity( + 0, + remoteGroup.name, + resolveFeedGroupIcon(remoteGroup.iconId), + -1 + ); + final long groupId = database.feedGroupDAO().insert(created); + if (groupId > 0) { + groupIdsByName.put(remoteGroup.name, groupId); + } + continue; + } + + groupIdsByName.put(remoteGroup.name, localGroup.getUid()); + final FeedGroupIcon desiredIcon = resolveFeedGroupIcon(remoteGroup.iconId); + if (localGroup.getIcon() != desiredIcon) { + localGroup.setIcon(desiredIcon); + database.feedGroupDAO().update(localGroup); + } + } + + for (final Map.Entry> entry : membershipByGroupName.entrySet()) { + final Long groupId = groupIdsByName.get(entry.getKey()); + if (groupId == null || groupId <= 0) { + continue; + } + final List subscriptionIds = new ArrayList<>(entry.getValue()); + database.feedGroupDAO().updateSubscriptionsForGroup(groupId, subscriptionIds); + } + }); + } + + @NonNull + private static JSONObject historyToJson(@NonNull final Map history) { + final List> entries = new ArrayList<>(history.entrySet()); + entries.sort((left, right) -> + Long.compare(right.getValue().accessTs, left.getValue().accessTs)); + + final JSONObject json = new JSONObject(); + int added = 0; + boolean truncated = false; + for (final Map.Entry entry : entries) { + try { + json.put(entry.getKey(), entry.getValue().toJson()); + added++; + } catch (final JSONException ignored) { + // Skip malformed record. + } + + if (added > MAX_HISTORY_RECORDS_PER_SNAPSHOT + || utf8Size(json.toString()) > MAX_CATEGORY_DATA_BYTES) { + json.remove(entry.getKey()); + truncated = true; + break; + } + } + if (truncated) { + Log.d(TAG, "Trimmed history snapshot from " + entries.size() + + " to " + json.length() + " entries"); + } + return json; + } + + @NonNull + private static JSONObject subscriptionsToJson( + @NonNull final Map subscriptions) { + final List> entries = + new ArrayList<>(subscriptions.entrySet()); + entries.sort(Map.Entry.comparingByKey()); + + final JSONObject json = new JSONObject(); + int added = 0; + boolean truncated = false; + for (final Map.Entry entry : entries) { + try { + json.put(entry.getKey(), entry.getValue().toJson()); + added++; + } catch (final JSONException ignored) { + // Skip malformed record. + } + + if (added > MAX_SUBSCRIPTIONS_PER_SNAPSHOT + || utf8Size(json.toString()) > MAX_CATEGORY_DATA_BYTES) { + json.remove(entry.getKey()); + truncated = true; + break; + } + } + if (truncated) { + Log.d(TAG, "Trimmed subscription snapshot from " + entries.size() + + " to " + json.length() + " entries"); + } + return json; + } + + private static int utf8Size(@NonNull final String text) { + return text.getBytes(StandardCharsets.UTF_8).length; + } + + private static int publishCategorySnapshot(@NonNull final Context context, + @NonNull final Set relays, + @Nullable final String nsec, + @Nullable final String signerPackage, + @NonNull final String pubKeyHex, + @NonNull final String category, + @NonNull final String dTagValue, + @NonNull final JSONObject data) { + final JSONObject payload = new JSONObject(); + final long now = Instant.now().getEpochSecond(); + try { + payload.put("v", 1); + payload.put("category", category); + payload.put("updated_at", now); + payload.put("data", data); + } catch (final JSONException e) { + return 0; + } + + final String encryptedContent; + if (!TextUtils.isEmpty(nsec)) { + try { + encryptedContent = NostrKeyUtils.encryptNip44(nsec, pubKeyHex, payload.toString()); + } catch (final RuntimeException e) { + return 0; + } + } else if (!TextUtils.isEmpty(signerPackage)) { + encryptedContent = Nip55SignerClient.nip44Encrypt( + context, + signerPackage, + payload.toString(), + pubKeyHex, + pubKeyHex + ); + if (TextUtils.isEmpty(encryptedContent)) { + Log.w(TAG, "Skipping publish: signer failed NIP-44 encrypt"); + return 0; + } + } else { + return 0; + } + final String eventContent; + try { + eventContent = new JSONObject() + .put("name", "newpipe-sync-" + category) + .put("data", new JSONObject() + .put("enc", "nip44") + .put("ciphertext", encryptedContent)) + .toString(); + } catch (final JSONException e) { + return 0; + } + + final JSONArray tags = new JSONArray(); + tags.put(new JSONArray().put("d").put(dTagValue)); + tags.put(new JSONArray().put("p").put(pubKeyHex)); + tags.put(new JSONArray().put("client").put("newpipe-sync")); + + final JSONObject unsignedEvent = new JSONObject(); + try { + unsignedEvent.put("pubkey", pubKeyHex); + unsignedEvent.put("created_at", now); + unsignedEvent.put("kind", KIND_APP_DATA); + unsignedEvent.put("tags", tags); + unsignedEvent.put("content", eventContent); + } catch (final JSONException e) { + return 0; + } + + final String serialized = serializeEventForId( + pubKeyHex, + now, + KIND_APP_DATA, + tags, + eventContent + ); + final byte[] eventHash = sha256(serialized.getBytes(StandardCharsets.UTF_8)); + final String computedEventId = bytesToHex(eventHash); + final JSONObject signedEvent; + + if (!TextUtils.isEmpty(nsec)) { + final String signature; + try { + signature = NostrKeyUtils.signEventId(nsec, eventHash); + signedEvent = new JSONObject(unsignedEvent.toString()) + .put("id", computedEventId) + .put("sig", signature); + } catch (final RuntimeException | JSONException e) { + return 0; + } + } else if (!TextUtils.isEmpty(signerPackage)) { + signedEvent = Nip55SignerClient.signEvent( + context, + signerPackage, + unsignedEvent, + pubKeyHex, + computedEventId + ); + if (signedEvent == null) { + Log.w(TAG, "Skipping publish: signer failed SIGN_EVENT"); + return 0; + } + } else { + return 0; + } + + final String eventId = nonEmpty(signedEvent.optString("id", null), computedEventId); + final String eventMessage = new JSONArray().put("EVENT").put(signedEvent).toString(); + int acceptedRelays = 0; + for (final String relay : relays) { + if (publishToRelay(relay, eventMessage, eventId)) { + acceptedRelays++; + } + } + Log.d(TAG, "Published " + category + " snapshot entries=" + data.length() + + " to " + acceptedRelays + "/" + relays.size() + " relays"); + return acceptedRelays; + } + + private static boolean publishToRelay(@NonNull final String relayUrl, + @NonNull final String eventMessage, + @NonNull final String eventId) { + final CountDownLatch openLatch = new CountDownLatch(1); + final CountDownLatch okLatch = new CountDownLatch(1); + final AtomicBoolean accepted = new AtomicBoolean(false); + final AtomicBoolean okReceived = new AtomicBoolean(false); + final Request request = new Request.Builder().url(relayUrl).build(); + final WebSocket webSocket = WS_CLIENT.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(@NonNull final WebSocket webSocket, + @NonNull final okhttp3.Response response) { + openLatch.countDown(); + } + + @Override + public void onMessage(@NonNull final WebSocket webSocket, + @NonNull final String text) { + try { + final JSONArray frame = new JSONArray(text); + if (frame.length() < 2 || !"OK".equals(frame.optString(0, ""))) { + return; + } + if (!eventId.equals(frame.optString(1, ""))) { + return; + } + + okReceived.set(true); + accepted.set(frame.optBoolean(2, false)); + if (!accepted.get()) { + Log.w(TAG, "Relay rejected event from " + relayUrl + ": " + + frame.optString(3, "")); + } + okLatch.countDown(); + } catch (final JSONException ignored) { + // Ignore malformed relay frames. + } + } + + @Override + public void onFailure(@NonNull final WebSocket webSocket, + @NonNull final Throwable t, + @Nullable final okhttp3.Response response) { + openLatch.countDown(); + okLatch.countDown(); + } + + @Override + public void onClosed(@NonNull final WebSocket webSocket, final int code, + @NonNull final String reason) { + okLatch.countDown(); + } + }); + + try { + if (!openLatch.await(RELAY_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + webSocket.cancel(); + return false; + } + + if (!webSocket.send(eventMessage)) { + webSocket.close(1000, "send failed"); + return false; + } + + okLatch.await(RELAY_PUBLISH_TIMEOUT_MS, TimeUnit.MILLISECONDS); + webSocket.close(1000, "done"); + if (!okReceived.get()) { + Log.w(TAG, "Relay did not acknowledge event: " + relayUrl); + } + return accepted.get(); + } catch (final Exception e) { + webSocket.cancel(); + return false; + } + } + + @NonNull + private static String serializeEventForId(@NonNull final String pubkey, + final long createdAt, + final int kind, + @NonNull final JSONArray tags, + @NonNull final String content) { + final StringBuilder builder = new StringBuilder(content.length() + 128); + builder.append("[0,"); + appendCanonicalJsonString(builder, pubkey); + builder.append(',').append(createdAt).append(',').append(kind).append(','); + appendCanonicalTags(builder, tags); + builder.append(','); + appendCanonicalJsonString(builder, content); + builder.append(']'); + return builder.toString(); + } + + private static void appendCanonicalTags(@NonNull final StringBuilder sink, + @NonNull final JSONArray tags) { + sink.append('['); + for (int i = 0; i < tags.length(); i++) { + if (i > 0) { + sink.append(','); + } + final JSONArray tag = tags.optJSONArray(i); + if (tag == null) { + sink.append("[]"); + continue; + } + + sink.append('['); + for (int j = 0; j < tag.length(); j++) { + if (j > 0) { + sink.append(','); + } + appendCanonicalJsonString(sink, String.valueOf(tag.opt(j))); + } + sink.append(']'); + } + sink.append(']'); + } + + private static void appendCanonicalJsonString(@NonNull final StringBuilder sink, + @Nullable final String value) { + final String text = value == null ? "" : value; + sink.append('"'); + for (int i = 0; i < text.length(); i++) { + final char ch = text.charAt(i); + switch (ch) { + case '"': + sink.append("\\\""); + break; + case '\\': + sink.append("\\\\"); + break; + case '\b': + sink.append("\\b"); + break; + case '\f': + sink.append("\\f"); + break; + case '\n': + sink.append("\\n"); + break; + case '\r': + sink.append("\\r"); + break; + case '\t': + sink.append("\\t"); + break; + default: + if (ch < 0x20) { + sink.append(String.format(Locale.US, "\\u%04x", (int) ch)); + } else { + sink.append(ch); + } + break; + } + } + sink.append('"'); + } + + @NonNull + private static byte[] sha256(@NonNull final byte[] input) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(input); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + @NonNull + private static String bytesToHex(@NonNull final byte[] bytes) { + final char[] alphabet = "0123456789abcdef".toCharArray(); + final char[] chars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + final int v = bytes[i] & 0xff; + chars[i * 2] = alphabet[v >>> 4]; + chars[i * 2 + 1] = alphabet[v & 0x0f]; + } + return new String(chars); + } + + @NonNull + private static String composeKey(final int serviceId, @NonNull final String url) { + return serviceId + "|" + url; + } + + @Nullable + private static String nonEmpty(@Nullable final String preferred, + @Nullable final String fallback) { + return TextUtils.isEmpty(preferred) ? fallback : preferred; + } + + @Nullable + private static String trimToNull(@Nullable final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @NonNull + private static FeedGroupIcon resolveFeedGroupIcon(final int iconId) { + for (final FeedGroupIcon icon : FeedGroupIcon.values()) { + if (icon.getId() == iconId) { + return icon; + } + } + return FeedGroupIcon.ALL; + } + + private static final class HistoryRecord { + final int serviceId; + final String url; + String title; + String streamType; + long duration; + String uploader; + String uploaderUrl; + String thumbnailUrl; + long progressMillis; + long accessTs; + long repeatCount; + + HistoryRecord(final int serviceId, + @NonNull final String url, + @Nullable final String title, + @Nullable final String streamType, + final long duration, + @Nullable final String uploader, + @Nullable final String uploaderUrl, + @Nullable final String thumbnailUrl, + final long progressMillis, + final long accessTs, + final long repeatCount) { + this.serviceId = serviceId; + this.url = url; + this.title = title; + this.streamType = streamType; + this.duration = duration; + this.uploader = uploader; + this.uploaderUrl = uploaderUrl; + this.thumbnailUrl = thumbnailUrl; + this.progressMillis = progressMillis; + this.accessTs = accessTs; + this.repeatCount = repeatCount; + } + + @Nullable + static HistoryRecord fromJson(@NonNull final JSONObject json) { + final int serviceId = json.optInt("service_id", Integer.MIN_VALUE); + final String url = json.optString("url", null); + if (serviceId == Integer.MIN_VALUE || TextUtils.isEmpty(url)) { + return null; + } + return new HistoryRecord( + serviceId, + url, + json.optString("title", null), + json.optString("stream_type", StreamType.VIDEO_STREAM.name()), + json.optLong("duration", -1), + json.optString("uploader", null), + json.optString("uploader_url", null), + json.optString("thumbnail_url", null), + Math.max(-1, json.optLong("progress_millis", -1)), + json.optLong("access_ts", 0), + Math.max(0, json.optLong("repeat_count", 0)) + ); + } + + @NonNull + static HistoryRecord fromEntry(@NonNull final StreamHistoryEntry entry, + final long progressMillis) { + final StreamEntity stream = entry.getStreamEntity(); + final long accessTs = entry.getAccessDate().toEpochSecond(); + return new HistoryRecord( + stream.getServiceId(), + stream.getUrl(), + stream.getTitle(), + stream.getStreamType().name(), + stream.getDuration(), + stream.getUploader(), + stream.getUploaderUrl(), + stream.getThumbnailUrl(), + Math.max(-1, progressMillis), + accessTs, + entry.getRepeatCount() + ); + } + + @NonNull + JSONObject toJson() throws JSONException { + return new JSONObject() + .put("service_id", serviceId) + .put("url", url) + .put("title", title) + .put("stream_type", streamType) + .put("duration", duration) + .put("uploader", uploader) + .put("uploader_url", uploaderUrl) + .put("thumbnail_url", thumbnailUrl) + .put("progress_millis", progressMillis) + .put("access_ts", accessTs) + .put("repeat_count", repeatCount); + } + + @NonNull + StreamEntity toStreamEntity() { + final StreamType parsedType; + try { + parsedType = StreamType.valueOf( + TextUtils.isEmpty(streamType) + ? StreamType.VIDEO_STREAM.name() + : streamType.toUpperCase(Locale.US) + ); + } catch (final IllegalArgumentException e) { + return new StreamEntity( + 0, + serviceId, + url, + nonEmpty(title, url), + StreamType.VIDEO_STREAM, + duration, + nonEmpty(uploader, ""), + uploaderUrl, + thumbnailUrl, + null, + null, + null, + null + ); + } + + return new StreamEntity( + 0, + serviceId, + url, + nonEmpty(title, url), + parsedType, + duration, + nonEmpty(uploader, ""), + uploaderUrl, + thumbnailUrl, + null, + null, + null, + null + ); + } + } + + private static final class SubscriptionRecord { + final int serviceId; + final String url; + String name; + String avatarUrl; + Long subscriberCount; + String description; + final Map groups = new HashMap<>(); + + SubscriptionRecord(final int serviceId, + @NonNull final String url, + @Nullable final String name, + @Nullable final String avatarUrl, + @Nullable final Long subscriberCount, + @Nullable final String description) { + this.serviceId = serviceId; + this.url = url; + this.name = name; + this.avatarUrl = avatarUrl; + this.subscriberCount = subscriberCount; + this.description = description; + } + + @Nullable + static SubscriptionRecord fromJson(@NonNull final JSONObject json) { + final int serviceId = json.optInt("service_id", Integer.MIN_VALUE); + final String url = json.optString("url", null); + if (serviceId == Integer.MIN_VALUE || TextUtils.isEmpty(url)) { + return null; + } + return new SubscriptionRecord( + serviceId, + url, + json.optString("name", null), + json.optString("avatar_url", null), + json.has("subscriber_count") + ? json.optLong("subscriber_count") + : null, + json.optString("description", null) + ).applyGroups(json.optJSONArray("groups")); + } + + @NonNull + static SubscriptionRecord fromEntity(@NonNull final SubscriptionEntity entity) { + return new SubscriptionRecord( + entity.getServiceId(), + entity.getUrl(), + entity.getName(), + entity.getAvatarUrl(), + entity.getSubscriberCount(), + entity.getDescription() + ); + } + + @NonNull + JSONObject toJson() throws JSONException { + final JSONObject json = new JSONObject() + .put("service_id", serviceId) + .put("url", url) + .put("name", name) + .put("avatar_url", avatarUrl) + .put("description", description); + if (subscriberCount != null) { + json.put("subscriber_count", subscriberCount); + } + if (!groups.isEmpty()) { + final List sortedGroups = new ArrayList<>(groups.values()); + sortedGroups.sort((left, right) -> + left.name.compareToIgnoreCase(right.name)); + final JSONArray groupsArray = new JSONArray(); + for (final GroupRecord group : sortedGroups) { + groupsArray.put(group.toJson()); + } + json.put("groups", groupsArray); + } + return json; + } + + @NonNull + SubscriptionEntity toEntity() { + return new SubscriptionEntity( + 0, + serviceId, + url, + name, + avatarUrl, + subscriberCount, + description, + NotificationMode.DISABLED + ); + } + + boolean applyTo(@NonNull final SubscriptionEntity target) { + boolean changed = false; + if (!TextUtils.equals(target.getName(), name) && !TextUtils.isEmpty(name)) { + target.setName(name); + changed = true; + } + if (!TextUtils.equals(target.getAvatarUrl(), avatarUrl) + && !TextUtils.isEmpty(avatarUrl)) { + target.setAvatarUrl(avatarUrl); + changed = true; + } + if (!TextUtils.equals(target.getDescription(), description) + && !TextUtils.isEmpty(description)) { + target.setDescription(description); + changed = true; + } + if (subscriberCount != null + && (target.getSubscriberCount() == null + || target.getSubscriberCount() < subscriberCount)) { + target.setSubscriberCount(subscriberCount); + changed = true; + } + return changed; + } + + @NonNull + private SubscriptionRecord applyGroups(@Nullable final JSONArray groupsArray) { + if (groupsArray == null) { + return this; + } + for (int i = 0; i < groupsArray.length(); i++) { + final JSONObject groupJson = groupsArray.optJSONObject(i); + if (groupJson != null) { + mergeGroup(GroupRecord.fromJson(groupJson)); + } else { + final String groupName = groupsArray.optString(i, null); + if (!TextUtils.isEmpty(groupName)) { + mergeGroup(new GroupRecord(groupName, FeedGroupIcon.ALL.getId())); + } + } + } + return this; + } + + void mergeGroupsFrom(@NonNull final SubscriptionRecord incoming) { + for (final GroupRecord group : incoming.groups.values()) { + mergeGroup(group); + } + } + + void mergeGroup(@Nullable final GroupRecord group) { + if (group == null || TextUtils.isEmpty(group.name)) { + return; + } + + final GroupRecord existing = groups.get(group.name); + if (existing == null) { + groups.put(group.name, new GroupRecord(group.name, group.iconId)); + return; + } + if (existing.iconId == FeedGroupIcon.ALL.getId() + && group.iconId != FeedGroupIcon.ALL.getId()) { + existing.iconId = group.iconId; + } + } + } + + private static final class GroupRecord { + final String name; + int iconId; + + GroupRecord(@NonNull final String name, final int iconId) { + this.name = name; + this.iconId = iconId; + } + + @NonNull + static GroupRecord fromJson(@NonNull final JSONObject json) { + final String name = json.optString("name", ""); + final int iconId = json.optInt("icon_id", FeedGroupIcon.ALL.getId()); + return new GroupRecord(name, iconId); + } + + @NonNull + JSONObject toJson() throws JSONException { + return new JSONObject() + .put("name", name) + .put("icon_id", iconId); + } + } + + public static final class RelayStatus { + public final int connectedRelays; + public final int totalRelays; + + RelayStatus(final int connectedRelays, final int totalRelays) { + this.connectedRelays = connectedRelays; + this.totalRelays = totalRelays; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/nostr/PortraitCaptureActivity.java b/app/src/main/java/org/schabi/newpipe/local/nostr/PortraitCaptureActivity.java new file mode 100644 index 00000000000..54272323c1f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/nostr/PortraitCaptureActivity.java @@ -0,0 +1,6 @@ +package org.schabi.newpipe.local.nostr; + +import com.journeyapps.barcodescanner.CaptureActivity; + +public class PortraitCaptureActivity extends CaptureActivity { +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index e9bb9d831e9..cc6a8a8a11d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -34,6 +34,7 @@ import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.local.nostr.NostrSyncManager import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog @@ -68,6 +69,8 @@ class SubscriptionFragment : BaseStateFragment() { private lateinit var feedGroupsSortMenuItem: GroupsHeader private val subscriptionsSection = Section() + private val syncPollRunnable = Runnable { pollSyncCompletion() } + @State @JvmField var itemsListState: Parcelable? = null @@ -101,6 +104,11 @@ class SubscriptionFragment : BaseStateFragment() { } override fun onDestroyView() { + if (_binding != null) { + binding.itemsList.removeCallbacks(syncPollRunnable) + binding.subscriptionSwipeRefreshLayout.setOnRefreshListener(null) + binding.subscriptionSwipeRefreshLayout.isRefreshing = false + } super.onDestroyView() _binding = null } @@ -193,6 +201,7 @@ class SubscriptionFragment : BaseStateFragment() { override fun initViews(rootView: View, savedInstanceState: Bundle?) { super.initViews(rootView, savedInstanceState) _binding = FragmentSubscriptionBinding.bind(rootView) + binding.subscriptionSwipeRefreshLayout.setOnRefreshListener { requestNostrSyncFromPull() } groupAdapter.spanCount = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) getGridSpanCountChannels(context) else 1 binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { @@ -342,6 +351,7 @@ class SubscriptionFragment : BaseStateFragment() { when (result) { is SubscriptionState.LoadedState -> { + stopPullToRefresh() result.subscriptions.forEach { if (it is ChannelItem) { it.gesturesListener = listenerChannelItem @@ -363,6 +373,7 @@ class SubscriptionFragment : BaseStateFragment() { } is SubscriptionState.ErrorState -> { + stopPullToRefresh() result.error?.let { showError(ErrorInfo(result.error, UserAction.SOMETHING_ELSE, "Subscriptions")) } @@ -418,7 +429,32 @@ class SubscriptionFragment : BaseStateFragment() { binding.itemsList.animate(true, 200) } + private fun requestNostrSyncFromPull() { + NostrSyncManager.requestSync(requireContext()) + binding.itemsList.removeCallbacks(syncPollRunnable) + binding.itemsList.postDelayed(syncPollRunnable, PULL_REFRESH_POLL_DELAY_MS) + } + + private fun pollSyncCompletion() { + val viewBinding = _binding ?: return + if (!NostrSyncManager.isSyncRunning()) { + stopPullToRefresh() + return + } + viewBinding.itemsList.postDelayed(syncPollRunnable, PULL_REFRESH_POLL_DELAY_MS) + } + + private fun stopPullToRefresh() { + val viewBinding = _binding ?: return + viewBinding.itemsList.removeCallbacks(syncPollRunnable) + if (viewBinding.subscriptionSwipeRefreshLayout.isRefreshing) { + viewBinding.subscriptionSwipeRefreshLayout.isRefreshing = false + } + } + companion object { + private const val PULL_REFRESH_POLL_DELAY_MS = 250L + val JSON_MIME_TYPE = MimeTypeMap.getSingleton() .getMimeTypeFromExtension("json") ?: "application/octet-stream" } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index 5cf378cc39f..78c652d80c6 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -16,10 +16,12 @@ import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.service.FeedUpdateInfo +import org.schabi.newpipe.local.nostr.NostrSyncManager import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.image.ImageStrategy class SubscriptionManager(context: Context) { + private val appContext = context.applicationContext private val database = NewPipeDatabase.getInstance(context) private val subscriptionTable = database.subscriptionDAO() private val feedDatabaseManager = FeedDatabaseManager(context) @@ -60,6 +62,8 @@ class SubscriptionManager(context: Context) { feedDatabaseManager.upsertAll(listEntities[index].uid, streams) } } + + NostrSyncManager.requestSync(appContext) } fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) @@ -73,7 +77,7 @@ class SubscriptionManager(context: Context) { } subscriptionTable.update(it) } - } + }.doOnComplete { NostrSyncManager.requestSync(appContext) } fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { return subscriptionTable().getSubscription(serviceId, url) @@ -109,14 +113,17 @@ class SubscriptionManager(context: Context) { return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) + .doOnComplete { NostrSyncManager.requestSync(appContext) } } fun insertSubscription(subscriptionEntity: SubscriptionEntity) { subscriptionTable.insert(subscriptionEntity) + NostrSyncManager.requestSync(appContext) } fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { subscriptionTable.delete(subscriptionEntity) + NostrSyncManager.requestSync(appContext) } /** diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 0a7906b8d75..e4ec44515ec 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -53,6 +53,7 @@ import org.schabi.newpipe.local.bookmark.BookmarkFragment; import org.schabi.newpipe.local.feed.FeedFragment; import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; +import org.schabi.newpipe.local.nostr.NostrSyncFragment; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; @@ -560,6 +561,13 @@ public static void openStatisticFragment(final FragmentManager fragmentManager) .commit(); } + public static void openNostrSyncFragment(final FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new NostrSyncFragment()) + .addToBackStack(null) + .commit(); + } + public static void openSubscriptionsImportFragment(final FragmentManager fragmentManager, final int serviceId) { defaultTransaction(fragmentManager) diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 00000000000..686bb613aac --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_qr_code_scanner.xml b/app/src/main/res/drawable/ic_qr_code_scanner.xml new file mode 100644 index 00000000000..ea3e6d26bc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 00000000000..fd7980cd16b --- /dev/null +++ b/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 00000000000..e985ee68045 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/dialog_nostr_create_account.xml b/app/src/main/res/layout/dialog_nostr_create_account.xml new file mode 100644 index 00000000000..f424ebdff4f --- /dev/null +++ b/app/src/main/res/layout/dialog_nostr_create_account.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_nostr_identity.xml b/app/src/main/res/layout/dialog_nostr_identity.xml new file mode 100644 index 00000000000..fc561ff3b4c --- /dev/null +++ b/app/src/main/res/layout/dialog_nostr_identity.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_nostr_sign_in.xml b/app/src/main/res/layout/dialog_nostr_sign_in.xml new file mode 100644 index 00000000000..6e6dda218af --- /dev/null +++ b/app/src/main/res/layout/dialog_nostr_sign_in.xml @@ -0,0 +1,183 @@ + + + + + + + +