Skip to content

Commit e461bc9

Browse files
committed
feat: implement private sync over Nostr protocol
1 parent 4c82e8b commit e461bc9

31 files changed

Lines changed: 4988 additions & 16 deletions

NOSTR_SYNC.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Nostr Sync
2+
3+
## What This PR Adds
4+
5+
This PR adds a new **Nostr Sync** feature area and end-to-end private sync for:
6+
7+
1. Watch history (including per-video resume position)
8+
2. Subscriptions (including subscription group membership)
9+
10+
The sync transport is Nostr app data events:
11+
12+
1. **Kind**: `30078`
13+
2. **Storage convention**: `NIP-78`
14+
3. **Payload encryption**: `NIP-44`
15+
4. **External signer integration**: `NIP-55` (Amber-compatible)
16+
17+
## User-Facing Changes
18+
19+
### Navigation and screen
20+
21+
1. Added a new **Nostr Sync** section in the left panel.
22+
2. Nostr Sync screen contains:
23+
- `Sync watch history` toggle
24+
- `Sync subscriptions` toggle
25+
- `Sign In` (when no identity)
26+
- `Show Identity` + `Clear Identity` (when identity exists)
27+
- Relay checklist with:
28+
- add relay
29+
- remove relay
30+
- reset relays to defaults
31+
32+
### Identity flows
33+
34+
When identity is not set:
35+
36+
1. `Sign In` opens dialog.
37+
2. If NIP-55 signer app exists:
38+
- Prompt to use signer app.
39+
3. If signer app is missing:
40+
- Prompt to install Amber.
41+
4. `Advanced` section supports:
42+
- Generate local keypair
43+
- Scan `nsec` from QR
44+
- Paste `nsec` in input field and import via dialog `Done`
45+
46+
When identity is set:
47+
48+
1. `Show Identity` dialog shows:
49+
- `npub` + copy + QR
50+
- `nsec` (if local) masked by default + eye toggle + copy + QR
51+
- profile image if available
52+
2. For signer-managed identities (no local `nsec`):
53+
- `nsec` label remains visible
54+
- explanatory message shown under label
55+
3. `Clear Identity` shows destructive confirmation.
56+
57+
### Pull-to-sync
58+
59+
1. Added pull-to-refresh-triggered sync in:
60+
- History view
61+
- Subscriptions view
62+
2. Refresh indicator remains visible while sync is running and stops when it completes/fails.
63+
64+
## Relay Configuration
65+
66+
Configured relay list:
67+
68+
- `wss://relay.primal.net`
69+
- `wss://relay.damus.io`
70+
- `wss://relay.snort.social`
71+
- `wss://nostr.oxtr.dev`
72+
- `wss://nos.lol`
73+
- `wss://nostr.bitcoiner.social`
74+
- `wss://nostr.semisol.dev`
75+
- `wss://shu01.shugur.net`
76+
- `wss://shu02.shugur.net`
77+
- `wss://shu03.shugur.net`
78+
- `wss://shu04.shugur.net`
79+
- `wss://shu05.shugur.net`
80+
81+
Default enabled relays for fresh config:
82+
83+
1. `wss://relay.primal.net`
84+
2. `wss://relay.damus.io`
85+
3. `wss://relay.snort.social`
86+
4. `wss://nostr.oxtr.dev`
87+
5. `wss://nos.lol`
88+
6. `wss://nostr.bitcoiner.social`
89+
7. `wss://nostr.semisol.dev`
90+
91+
All `shu*.shugur.net` relays are unchecked by default.
92+
93+
Relay list is user-configurable in-app:
94+
95+
1. Add custom relay URL
96+
2. Remove any relay entry
97+
3. Reset list and checked state back to defaults
98+
99+
## Sync Protocol and Event Shape
100+
101+
### Event kind and filtering
102+
103+
1. Sync reads/writes Nostr events with kind `30078`.
104+
2. Relay query filter includes:
105+
- `kinds: [30078]`
106+
- author pubkey
107+
108+
### Payload envelope
109+
110+
Published event content wraps encrypted payload in JSON:
111+
112+
- `data.enc = "nip44"`
113+
- `data.ciphertext = <nip44 ciphertext>`
114+
115+
The encrypted plaintext payload includes:
116+
117+
1. `v` (payload version)
118+
2. `category` (`watch_history` or `subscriptions`)
119+
3. `updated_at`
120+
4. `data` object (actual merged records)
121+
122+
Event tags include:
123+
124+
1. `d` tag (device/category-scoped)
125+
2. `p` tag (self pubkey)
126+
3. `client` tag (`newpipe-sync`)
127+
128+
### Sign/encrypt modes
129+
130+
Two modes are supported:
131+
132+
1. **Local key mode** (`nsec` present):
133+
- NIP-44 encrypt/decrypt locally
134+
- event signing locally
135+
2. **Signer-only mode** (no local `nsec`, NIP-55 identity):
136+
- NIP-44 encrypt/decrypt via signer app
137+
- event signing via signer app
138+
139+
## Merge Strategy (CRDT-style)
140+
141+
This implementation uses deterministic per-record merge rules (CRDT-style behavior), so data from multiple devices converges.
142+
143+
### Watch history merge
144+
145+
Keyed by `serviceId + url`.
146+
147+
For each record:
148+
149+
1. `repeatCount = max(local, remote)`
150+
2. Newer `accessTs` wins for time-sensitive fields (title/type/duration/uploader/thumbnail)
151+
3. Missing fields are filled from the other side when possible
152+
4. `progressMillis` (resume position) follows newer `accessTs`; if winner has no value,
153+
keep the available non-negative value from the other side; on equal `accessTs`, max progress wins
154+
155+
DB apply rule:
156+
157+
1. Upsert stream row
158+
2. Compare with latest history entry
159+
3. Store merged `accessDate = max(local, remote)` and merged `repeatCount = max(local, remote)`
160+
4. Upsert `stream_state.progress_time` when merged record includes `progressMillis`
161+
162+
### Subscription merge
163+
164+
Keyed by `serviceId + url`.
165+
166+
For each record:
167+
168+
1. Text fields kept if non-empty (prefer existing non-empty, fill blanks)
169+
2. `subscriberCount` uses max positive value when available
170+
3. Group memberships merged as union by group name
171+
4. Group icon conflict rule prefers non-default icon over default `ALL`
172+
173+
DB apply rule:
174+
175+
1. Insert missing subscriptions
176+
2. Update existing only if merged values changed
177+
3. Reconcile feed groups by name (create/update)
178+
4. Rewrite membership for each group from merged membership set
179+
180+
## Robustness and UX Fixes Included
181+
182+
1. Crash fixes around Nostr Sync screen initialization.
183+
2. Icon updates (`sync`, QR icon variant).
184+
3. Portrait-locked QR scanning capture activity.
185+
4. NIP-55 handling fixes for signer result parsing and package resolution.
186+
5. Improved identity dialog layout/visibility behavior across screen sizes.
187+
188+
## Known Scope Boundaries
189+
190+
1. Watch later playlist sync is intentionally not implemented in the active flow.
191+
2. Public playlist publication is not implemented in this PR.
192+
193+
## Testing Notes
194+
195+
Manual flows covered during development:
196+
197+
1. Local `nsec` identity generation/import/clear/show
198+
2. Signer-managed identity via NIP-55
199+
3. History sync across devices with same identity and relays
200+
4. Resume-position sync (`stream_state.progress_time`) across devices
201+
5. Subscription sync including grouped subscriptions
202+
6. Pull-to-sync behavior in History and Subscriptions
203+
7. Relay enable/disable behavior
204+
205+
## Files of Interest
206+
207+
Core:
208+
209+
1. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncManager.java`
210+
2. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrSyncFragment.java`
211+
3. `app/src/main/java/org/schabi/newpipe/local/nostr/Nip55SignerClient.java`
212+
4. `app/src/main/java/org/schabi/newpipe/local/nostr/NostrKeyUtils.java`
213+
214+
DAO:
215+
216+
1. `app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt`
217+
2. `app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt`
218+
3. `app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt`
219+
4. `app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt`
220+
221+
UI:
222+
223+
1. `app/src/main/res/layout/fragment_nostr_sync.xml`
224+
2. `app/src/main/res/layout/dialog_nostr_sign_in.xml`
225+
3. `app/src/main/res/layout/dialog_nostr_identity.xml`
226+
4. `app/src/main/res/layout/fragment_history_playlist.xml`
227+
5. `app/src/main/res/layout/fragment_subscription.xml`

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,9 @@ dependencies {
293293

294294
/** Third-party libraries **/
295295
// Instance state boilerplate elimination
296+
implementation(libs.bouncycastle.bcprov)
296297
implementation(libs.livefront.bridge)
298+
implementation(libs.journeyapps.zxing.android.embedded)
297299
implementation(libs.evernote.statesaver.core)
298300
kapt(libs.evernote.statesaver.compiler)
299301

app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
<action android:name="android.intent.action.VIEW" />
2020
<data android:scheme="http" />
2121
</intent>
22+
<intent>
23+
<action android:name="android.intent.action.VIEW" />
24+
<category android:name="android.intent.category.BROWSABLE" />
25+
<data android:scheme="nostrsigner" />
26+
</intent>
27+
<package android:name="com.greenart7c3.nostrsigner" />
2228
</queries>
2329

2430
<uses-feature
@@ -101,6 +107,11 @@
101107
android:exported="false"
102108
android:label="@string/title_activity_about" />
103109

110+
<activity
111+
android:name=".local.nostr.PortraitCaptureActivity"
112+
android:screenOrientation="portrait"
113+
android:stateNotNeeded="true" />
114+
104115
<service
105116
android:name="androidx.work.impl.foreground.SystemForegroundService"
106117
android:foregroundServiceType="dataSync"

app/src/main/java/org/schabi/newpipe/MainActivity.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
7272
import org.schabi.newpipe.fragments.list.search.SearchFragment;
7373
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
74+
import org.schabi.newpipe.local.nostr.NostrSyncManager;
7475
import org.schabi.newpipe.player.Player;
7576
import org.schabi.newpipe.player.event.OnKeyDownListener;
7677
import org.schabi.newpipe.player.helper.PlayerHolder;
@@ -117,6 +118,7 @@ public class MainActivity extends AppCompatActivity {
117118
private static final int ITEM_ID_BOOKMARKS = -3;
118119
private static final int ITEM_ID_DOWNLOADS = -4;
119120
private static final int ITEM_ID_HISTORY = -5;
121+
private static final int ITEM_ID_NOSTR_SYNC = -6;
120122
private static final int ITEM_ID_SETTINGS = 0;
121123
private static final int ITEM_ID_DONATION = 1;
122124
private static final int ITEM_ID_ABOUT = 2;
@@ -276,6 +278,9 @@ private void addDrawerMenuForCurrentService() throws ExtractionException {
276278
drawerLayoutBinding.navigation.getMenu()
277279
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
278280
.setIcon(R.drawable.ic_history);
281+
drawerLayoutBinding.navigation.getMenu()
282+
.add(R.id.menu_tabs_group, ITEM_ID_NOSTR_SYNC, ORDER, R.string.nostr_sync)
283+
.setIcon(R.drawable.ic_sync);
279284

280285
//Kiosks
281286
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
@@ -353,6 +358,9 @@ private void tabSelected(final MenuItem item) {
353358
case ITEM_ID_HISTORY:
354359
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
355360
break;
361+
case ITEM_ID_NOSTR_SYNC:
362+
NavigationHelper.openNostrSyncFragment(getSupportFragmentManager());
363+
break;
356364
}
357365
}
358366

@@ -543,6 +551,8 @@ protected void onResume() {
543551
getString(R.string.enable_watch_history_key), true);
544552
drawerLayoutBinding.navigation.getMenu().findItem(ITEM_ID_HISTORY)
545553
.setVisible(isHistoryEnabled);
554+
555+
NostrSyncManager.requestSync(this);
546556
}
547557

548558
@Override

app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ abstract class FeedGroupDAO {
1717
@Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
1818
abstract fun getAll(): Flowable<List<FeedGroupEntity>>
1919

20+
@Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
21+
abstract fun getAllBlocking(): List<FeedGroupEntity>
22+
2023
@Query("SELECT * FROM feed_group WHERE uid = :groupId")
2124
abstract fun getGroup(groupId: Long): Maybe<FeedGroupEntity>
2225

@@ -39,6 +42,9 @@ abstract class FeedGroupDAO {
3942
@Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
4043
abstract fun getSubscriptionIdsFor(groupId: Long): Flowable<List<Long>>
4144

45+
@Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
46+
abstract fun getSubscriptionIdsForBlocking(groupId: Long): List<Long>
47+
4248
@Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId")
4349
abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int
4450

app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
3434
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
3535
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
3636

37+
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
38+
abstract fun getHistorySortedByIdBlocking(): List<StreamHistoryEntry>
39+
3740
@Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
3841
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
3942

app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
2222
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
2323
override fun getAll(): Flowable<List<StreamStateEntity>>
2424

25+
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
26+
fun getAllBlocking(): List<StreamStateEntity>
27+
2528
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
2629
override fun deleteAll(): Int
2730

@@ -32,6 +35,12 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
3235
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
3336
fun getState(streamId: Long): Maybe<StreamStateEntity>
3437

38+
@Query(
39+
"SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE +
40+
" WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId LIMIT 1"
41+
)
42+
fun getStateBlocking(streamId: Long): StreamStateEntity?
43+
3544
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
3645
fun deleteState(streamId: Long): Int
3746

app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
2121
@Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
2222
abstract override fun getAll(): Flowable<List<SubscriptionEntity>>
2323

24+
@Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
25+
abstract fun getAllBlocking(): List<SubscriptionEntity>
26+
2427
@Query(
2528
"""
2629
SELECT * FROM subscriptions

0 commit comments

Comments
 (0)