diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ced8459f2..61a22490c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -176,6 +176,12 @@ dependencies { implementation(libs.activity.compose) implementation(libs.appcompat) + // Markdown + implementation(libs.jetbrains.markdown) + + // Nostr + implementation(libs.rust.nostr) + // Testing testImplementation(libs.junit) androidTestImplementation(libs.junit.ext) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8958ffb82..7ba0922f3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -37,6 +37,14 @@ # Provider API -keep class me.ash.reader.** { *; } +# Nostr +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-dontwarn java.awt.Component +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Window + # https://github.com/flutter/flutter/issues/127388 -dontwarn org.kxml2.io.KXml** diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 37e51657e..5eaff303d 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -6,7 +6,6 @@ import androidx.paging.PagingSource import androidx.work.CoroutineWorker import androidx.work.ListenableWorker import androidx.work.WorkManager -import com.rometools.rome.feed.synd.SyndFeed import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -15,7 +14,6 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -32,9 +30,13 @@ import me.ash.reader.domain.repository.GroupDao import me.ash.reader.infrastructure.android.NotificationHelper import me.ash.reader.infrastructure.preference.KeepArchivedPreference import me.ash.reader.infrastructure.preference.SyncIntervalPreference +import me.ash.reader.infrastructure.rss.FetchedFeed +import me.ash.reader.infrastructure.rss.NostrFeed import me.ash.reader.infrastructure.rss.RssHelper +import me.ash.reader.infrastructure.rss.SyndFeedDelegate import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.decodeHTML +import me.ash.reader.ui.ext.isNostrUri import me.ash.reader.ui.ext.spacerDollar import java.util.Date import java.util.UUID @@ -63,19 +65,26 @@ abstract class AbstractRssRepository( open suspend fun clearAuthorization() {} open suspend fun subscribe( - feedLink: String, searchedFeed: SyndFeed, groupId: String, + feedLink: String, searchedFeed: FetchedFeed, groupId: String, isNotification: Boolean, isFullContent: Boolean ) { val accountId = context.currentAccountId val feed = Feed( id = accountId.spacerDollar(UUID.randomUUID().toString()), - name = searchedFeed.title.decodeHTML()!!, + name = with(searchedFeed.title){ if (this.isNostrUri()) this else this.decodeHTML()!!}, url = feedLink, groupId = groupId, accountId = accountId, - icon = searchedFeed.icon?.link + icon = searchedFeed.getIconLink() ) - val articles = searchedFeed.entries.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) } + val articles = when(searchedFeed) { + is NostrFeed -> searchedFeed.getArticles().map { + rssHelper.buildArticleFromNostrEvent(feed, accountId, it, searchedFeed.getFeedAuthor()) + } + is SyndFeedDelegate -> searchedFeed.getArticles().map { + rssHelper.buildArticleFromSyndEntry(feed, accountId, it) + } + } feedDao.insert(feed) articleDao.insertList(articles.map { it.copy(feedId = feed.id) @@ -178,17 +187,29 @@ abstract class AbstractRssRepository( private suspend fun syncFeed(feed: Feed, preDate: Date = Date()): FeedWithArticle { val latest = articleDao.queryLatestByFeedId(context.currentAccountId, feed.id) - val articles = rssHelper.queryRssXml(feed, "", preDate) - if (feed.icon == null) { - val iconLink = rssHelper.queryRssIconLink(feed.url) - if (iconLink != null) { - rssHelper.saveRssIcon(feedDao, feed, iconLink) + if (feed.url.isNostrUri()) { + val syncedFeed = rssHelper.syncNostrFeed(feed, "", preDate) + return FeedWithArticle( + feed = syncedFeed.feed + .apply { isNotification = feed.isNotification && syncedFeed.articles.isNotEmpty() }, + articles = syncedFeed.articles + ) + } + else { + val articles = rssHelper.queryRssXml(feed, "", preDate) + if (feed.icon == null) { + val iconLink = rssHelper.queryRssIconLink(feed.url) + if (iconLink != null) { + rssHelper.saveRssIcon(feedDao, feed, iconLink) + } } + + return FeedWithArticle( + feed = feed.apply { isNotification = feed.isNotification && articles.isNotEmpty() }, + articles = articles + ) } - return FeedWithArticle( - feed = feed.apply { isNotification = feed.isNotification && articles.isNotEmpty() }, - articles = articles - ) + } suspend fun clearKeepArchivedArticles() { diff --git a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt index d4462d738..20fed9f1b 100644 --- a/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt @@ -5,7 +5,6 @@ import android.util.Log import androidx.work.CoroutineWorker import androidx.work.ListenableWorker import androidx.work.WorkManager -import com.rometools.rome.feed.synd.SyndFeed import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.supervisorScope @@ -27,6 +26,7 @@ import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.MainDispatcher import me.ash.reader.infrastructure.exception.FeverAPIException import me.ash.reader.infrastructure.html.Readability +import me.ash.reader.infrastructure.rss.FetchedFeed import me.ash.reader.infrastructure.rss.RssHelper import me.ash.reader.infrastructure.rss.provider.fever.FeverAPI import me.ash.reader.infrastructure.rss.provider.fever.FeverDTO @@ -86,7 +86,7 @@ class FeverRssService @Inject constructor( } override suspend fun subscribe( - feedLink: String, searchedFeed: SyndFeed, groupId: String, + feedLink: String, searchedFeed: FetchedFeed, groupId: String, isNotification: Boolean, isFullContent: Boolean, ) { throw FeverAPIException("Unsupported") diff --git a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt index a53affabd..fe5355442 100644 --- a/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt @@ -5,7 +5,6 @@ import android.util.Log import androidx.work.CoroutineWorker import androidx.work.ListenableWorker import androidx.work.WorkManager -import com.rometools.rome.feed.synd.SyndFeed import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.supervisorScope @@ -25,6 +24,7 @@ import me.ash.reader.infrastructure.di.DefaultDispatcher import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.MainDispatcher import me.ash.reader.infrastructure.html.Readability +import me.ash.reader.infrastructure.rss.FetchedFeed import me.ash.reader.infrastructure.rss.RssHelper import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderAPI.Companion.ofCategoryIdToStreamId @@ -97,7 +97,7 @@ class GoogleReaderRssService @Inject constructor( } override suspend fun subscribe( - feedLink: String, searchedFeed: SyndFeed, groupId: String, + feedLink: String, searchedFeed: FetchedFeed, groupId: String, isNotification: Boolean, isFullContent: Boolean, ) { val accountId = context.currentAccountId diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt index 894862a1a..fe64c030d 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidApp.kt @@ -22,6 +22,7 @@ import me.ash.reader.ui.ext.del import me.ash.reader.ui.ext.getLatestApk import me.ash.reader.ui.ext.isGitHub import okhttp3.OkHttpClient +import rust.nostr.sdk.Client import javax.inject.Inject /** @@ -58,6 +59,9 @@ class AndroidApp : Application(), Configuration.Provider { @Inject lateinit var rssHelper: RssHelper + @Inject + lateinit var nostrClient: Client + @Inject lateinit var notificationHelper: NotificationHelper diff --git a/app/src/main/java/me/ash/reader/infrastructure/di/NostrClientModule.kt b/app/src/main/java/me/ash/reader/infrastructure/di/NostrClientModule.kt new file mode 100644 index 000000000..7f802df37 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/di/NostrClientModule.kt @@ -0,0 +1,19 @@ +package me.ash.reader.infrastructure.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import rust.nostr.sdk.Client +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NostrClientModule { + + @Provides + @Singleton + fun provideNostrClient(): Client { + return Client() + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/FetchedFeed.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/FetchedFeed.kt new file mode 100644 index 000000000..53b8794e6 --- /dev/null +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/FetchedFeed.kt @@ -0,0 +1,279 @@ +package me.ash.reader.infrastructure.rss + +import android.util.Log +import com.rometools.rome.feed.synd.SyndEntry +import com.rometools.rome.feed.synd.SyndFeed +import rust.nostr.sdk.Client +import rust.nostr.sdk.Event +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindEnum +import rust.nostr.sdk.Nip19Profile +import rust.nostr.sdk.Nip21 +import rust.nostr.sdk.Nip21Enum +import rust.nostr.sdk.NostrSdkException +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.TagKind +import rust.nostr.sdk.extractRelayList +import rust.nostr.sdk.getNip05Profile +import java.time.Duration + +sealed interface FetchedFeed { + fun getIconLink(): String + //The function below is for compatibility with SyndFeed + fun getIconUrl(): String + fun getFeedLink(): String + var title: String + fun getFeedAuthor(): String + fun getArticles(): List<*> +} + +class SyndFeedDelegate( + private val syndFeed: SyndFeed +): FetchedFeed { + + override fun getIconLink(): String { + return syndFeed.icon.link + } + + override fun getIconUrl(): String { + return syndFeed.icon.url + } + + override fun getFeedLink(): String { + return syndFeed.link + } + + override var title: String + get() = syndFeed.title + set(value) { + syndFeed.title = value + } + + override fun getFeedAuthor(): String { + return syndFeed.author + } + + override fun getArticles(): List { + return syndFeed.entries + } +} + +class NostrFeed( + private val nostrClient: Client +): FetchedFeed { + private val LOG_TAG = "ReadYou" + private lateinit var feedFetchResult: NostrFeedResult + + // The default relays to get info from, separated by purpose. + private val defaultFetchRelays = listOf("wss://relay.nostr.band", "wss://relay.damus.io") + private val defaultMetadataRelays = listOf("wss://purplepag.es", "wss://user.kindpag.es") + private val defaultArticleFetchRelays = setOf("wss://nos.lol") + defaultFetchRelays + + override fun getIconLink(): String { + return feedFetchResult.authorPictureLink + } + + override fun getIconUrl(): String { + return feedFetchResult.authorPictureLink + } + + override fun getFeedLink(): String { + return feedFetchResult.nostrUri + } + + override var title: String + get() = feedFetchResult.feedTitle + set(value) { + feedFetchResult.feedTitle = value + } + + override fun getFeedAuthor(): String { + return feedFetchResult.authorName + } + + override fun getArticles(): List { + return feedFetchResult.articles + } + + private suspend fun nreq(nostrUri: String): NostrFeedResult { + + val profile = getProfileMetadata(nostrUri) + val publishRelays = getUserPublishRelays(profile.publicKey) + + val articles = fetchArticlesForAuthor( + profile.publicKey, + publishRelays + ) + return NostrFeedResult( + nostrUri = nostrUri, + authorName = profile.name, + feedTitle = profile.name, + authorPictureLink = profile.imageUrl, + articles = articles + ) + } + + private suspend fun parseNostrUri(nostrUri: String): Nip19Profile { + if (nostrUri.contains("@")) { // It means it is a Nip05 address + val rawString = nostrUri.removePrefix("nostr:") + val parsedNip5 = getNip05Profile(rawString) + val (pubkey, relays) = parsedNip5.publicKey() to parsedNip5.relays() + return Nip19Profile(pubkey, relays) + } else { + val parsedProfile = Nip21.parse(nostrUri).asEnum() + when(parsedProfile) { + is Nip21Enum.Pubkey -> return Nip19Profile(parsedProfile.publicKey) + is Nip21Enum.Profile -> return Nip19Profile(parsedProfile.profile.publicKey(), parsedProfile.profile.relays()) + else -> throw Throwable(message = "Could not find the user's info: $nostrUri") + } + } + } + + private suspend fun getProfileMetadata(nostrUri: String): AuthorNostrData { + val possibleNostrProfile = parseNostrUri(nostrUri) + val publicKey = possibleNostrProfile.publicKey() + val relayList = + possibleNostrProfile.relays() + .takeIf { + it.size < 4 + }.orEmpty() + .ifEmpty { getUserPublishRelays(publicKey) } + Log.d(LOG_TAG, "getProfileMetadata: Relays from Nip19 -> ${relayList.joinToString(separator = ", ")}") + relayList + .ifEmpty { defaultFetchRelays } + .forEach { relayUrl -> + nostrClient.addReadRelay(relayUrl) + } + nostrClient.connect() + val profileInfo = + try { + nostrClient.fetchMetadata( + publicKey = publicKey, + timeout = Duration.ofSeconds(5L), + ) + } catch (e: NostrSdkException) { + // We will use a default relay regardless of whether it is added above, to keep things simple. + nostrClient.addReadRelay(defaultFetchRelays.random()) + nostrClient.connect() + nostrClient.fetchMetadata( + publicKey = publicKey, + timeout = Duration.ofSeconds(5L), + ) + } + Log.d(LOG_TAG, "getProfileMetadata: ${profileInfo.asPrettyJson()}") + + // Check if all relays in relaylist can be connected to + return AuthorNostrData( + uri = possibleNostrProfile.toNostrUri(), + name = profileInfo.getName().toString(), + publicKey = publicKey, + imageUrl = profileInfo.getPicture().toString(), + relayList = nostrClient.relays().map { relayEntry -> relayEntry.key }, + ) + } + + + private suspend fun getUserPublishRelays(userPubkey: PublicKey): List { + val userRelaysFilter = + Filter() + .author(userPubkey) + .kind( + Kind.fromEnum(KindEnum.RelayList), + ) + + nostrClient.removeAllRelays() + defaultMetadataRelays.forEach { relayUrl -> + nostrClient.addReadRelay(relayUrl) + } + nostrClient.connect() + val potentialUserRelays = + nostrClient.fetchEventsFrom( + urls = defaultMetadataRelays, + filters = listOf(userRelaysFilter), + timeout = Duration.ofSeconds(5), + ) + val relayList = extractRelayList(potentialUserRelays.toVec().first()) + val relaysToUse = + if (relayList.any { (_, relayType) -> relayType == RelayMetadata.WRITE }) { + relayList.filter { it.value == RelayMetadata.WRITE }.map { entry -> entry.key } + } else if (relayList.size < 7) { + relayList.map { entry -> entry.key } // This represents the relay URL, just as the operation above. + } else { + defaultArticleFetchRelays.map { it } + } + + return relaysToUse + } + + private suspend fun fetchArticlesForAuthor( + author: PublicKey, + relays: List, + ): List { + val articlesByAuthorFilter = + Filter() + .author(author) + .kind(Kind.fromEnum(KindEnum.LongFormTextNote)) + Log.d(LOG_TAG, "Relay List size: ${relays.size}") + + nostrClient.removeAllRelays() + val relaysToUse = + relays.take(3).plus(defaultArticleFetchRelays.random()) + .ifEmpty { defaultFetchRelays } + relaysToUse.forEach { relay -> nostrClient.addReadRelay(relay) } + nostrClient.connect() + Log.d(LOG_TAG, "FETCHING ARTICLES") + val articleEventSet = + nostrClient.fetchEventsFrom( + urls = relaysToUse, + filters = + listOf( + articlesByAuthorFilter, + ), + timeout = Duration.ofSeconds(10L), + ).toVec() + val articleEvents = articleEventSet.distinctBy { it.tags().find(TagKind.Title) } + Log.d(LOG_TAG, "fetchArticlesForAuthor: Article Set Size: ${articleEvents.size}") + nostrClient.removeAllRelays() // This is necessary to avoid piling relays to fetch from(on each fetch). + return articleEvents + } + + companion object { + suspend fun fetchFeedFrom(uri: String, nostrClient: Client): NostrFeed { + nostrClient.use { + val feedInstance = NostrFeed(nostrClient) + val feedResult = feedInstance.nreq(uri) + feedInstance.feedFetchResult = feedResult + return if (feedInstance.getArticles().isNotEmpty()){ + feedInstance + } else throw EmptyNostrDataException("No feed found for $uri") + } + + } + + suspend fun fetchFeedMetadata(uri: String, nostrClient: Client): AuthorNostrData { + val feedFetcher = NostrFeed(nostrClient) + return feedFetcher.getProfileMetadata(uri) + } + } + +} + +class AuthorNostrData( + val uri: String, + val name: String, + val publicKey: PublicKey, + val imageUrl: String, + val relayList: List +) + +class EmptyNostrDataException(override val message: String?): Exception(message) + +class NostrFeedResult( + val nostrUri: String, + val authorName: String, + var feedTitle: String, + val authorPictureLink: String, + val articles: List +) \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt index 91afbc038..7f731ba37 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/OPMLDataSource.kt @@ -10,7 +10,9 @@ import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.model.group.GroupWithFeed import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.ui.ext.extractDomain +import me.ash.reader.ui.ext.isNostrUri import me.ash.reader.ui.ext.spacerDollar +import rust.nostr.sdk.Client import java.io.InputStream import java.util.* import javax.inject.Inject @@ -48,17 +50,32 @@ class OPMLDataSource @Inject constructor( ) } } else { - groupWithFeedList.addFeedToDefault( + val feedUrl = outline.extractUrl() + val feedToAdd = if (feedUrl?.isNostrUri() == true) { + val feedMetadata = NostrFeed.fetchFeedMetadata(feedUrl, Client()) Feed( id = targetAccountId.spacerDollar(UUID.randomUUID().toString()), name = outline.extractName(), url = outline.extractUrl() ?: continue, + icon = feedMetadata.imageUrl, groupId = defaultGroup.id, accountId = targetAccountId, isNotification = outline.extractPresetNotification(), isFullContent = outline.extractPresetFullContent(), ) - ) + } + else { + Feed( + id = targetAccountId.spacerDollar(UUID.randomUUID().toString()), + name = outline.extractName(), + url = outline.extractUrl() ?: continue, + groupId = defaultGroup.id, + accountId = targetAccountId, + isNotification = outline.extractPresetNotification(), + isFullContent = outline.extractPresetFullContent(), + ) + } + groupWithFeedList.addFeedToDefault(feedToAdd) } } else { var groupId = defaultGroup.id @@ -74,17 +91,32 @@ class OPMLDataSource @Inject constructor( } for (subOutline in outline.subElements) { if (subOutline != null && subOutline.attributes != null) { - groupWithFeedList.addFeed( + val feedUrl = outline.extractUrl() + val feedToAdd = if (feedUrl?.isNostrUri() == true) { + val feedMetadata = NostrFeed.fetchFeedMetadata(feedUrl, Client()) Feed( id = targetAccountId.spacerDollar(UUID.randomUUID().toString()), name = subOutline.extractName(), url = subOutline.extractUrl() ?: continue, + icon = feedMetadata.imageUrl, groupId = groupId, accountId = targetAccountId, isNotification = subOutline.extractPresetNotification(), isFullContent = subOutline.extractPresetFullContent(), ) - ) + } + else { + Feed( + id = targetAccountId.spacerDollar(UUID.randomUUID().toString()), + name = subOutline.extractName(), + url = subOutline.extractUrl() ?: continue, + groupId = groupId, + accountId = targetAccountId, + isNotification = subOutline.extractPresetNotification(), + isFullContent = subOutline.extractPresetFullContent(), + ) + } + groupWithFeedList.addFeed(feedToAdd) } } } diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt index 698f7ffb5..b9c459842 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt @@ -3,7 +3,6 @@ package me.ash.reader.infrastructure.rss import android.content.Context import android.util.Log import com.rometools.rome.feed.synd.SyndEntry -import com.rometools.rome.feed.synd.SyndFeed import com.rometools.rome.feed.synd.SyndImageImpl import com.rometools.rome.io.SyndFeedInput import com.rometools.rome.io.XmlReader @@ -12,18 +11,30 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import me.ash.reader.domain.model.article.Article import me.ash.reader.domain.model.feed.Feed +import me.ash.reader.domain.model.feed.FeedWithArticle import me.ash.reader.domain.repository.FeedDao import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.html.Readability import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.decodeHTML import me.ash.reader.ui.ext.extractDomain +import me.ash.reader.ui.ext.htmlFromMarkdown import me.ash.reader.ui.ext.isFuture +import me.ash.reader.ui.ext.isNostrUri import me.ash.reader.ui.ext.spacerDollar import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.executeAsync +import rust.nostr.sdk.Alphabet +import rust.nostr.sdk.Client +import rust.nostr.sdk.Coordinate +import rust.nostr.sdk.Event +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindEnum +import rust.nostr.sdk.SingleLetterTag +import rust.nostr.sdk.TagKind import java.io.InputStream +import java.time.Instant import java.util.* import javax.inject.Inject @@ -39,15 +50,24 @@ class RssHelper @Inject constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, private val okHttpClient: OkHttpClient, + private val nostrClient: Client ) { @Throws(Exception::class) - suspend fun searchFeed(feedLink: String): SyndFeed { + suspend fun searchFeed(feedLink: String): FetchedFeed? { return withContext(ioDispatcher) { - SyndFeedInput().build(XmlReader(inputStream(okHttpClient, feedLink))).also { - it.icon = SyndImageImpl() - it.icon.link = queryRssIconLink(feedLink) - it.icon.url = it.icon.link + if(feedLink.isNostrUri()) { + NostrFeed.fetchFeedFrom(feedLink, nostrClient) + } + else { + val parsedSyndFeed = SyndFeedInput() + .build(XmlReader(inputStream(okHttpClient, feedLink))) + .also { + it.icon = SyndImageImpl() + it.icon.link = queryRssIconLink(feedLink) + it.icon.url = it.icon.link + } + SyndFeedDelegate(parsedSyndFeed) } } } @@ -126,6 +146,90 @@ class RssHelper @Inject constructor( ) } + @Throws(Exception::class) + suspend fun syncNostrFeed( + feed: Feed, + latestLink: String?, + preDate: Date = Date() + ): FeedWithArticle = + try { + val accountId = context.currentAccountId + Client().use { + val nostrFeed = NostrFeed.fetchFeedFrom(feed.url, it) + val updatedArticles = nostrFeed.getArticles() + .map { buildArticleFromNostrEvent(feed, accountId, it, nostrFeed.getFeedAuthor(), preDate) } + val updatedFeed = feed.copy( + icon = nostrFeed.getIconUrl() + ) + return FeedWithArticle(updatedFeed, updatedArticles) + } + } catch (e: Exception) { + e.printStackTrace() + Log.e("RLog", "syncNostrFeedNew[${feed.name}]: ${e.message}") + FeedWithArticle(feed, emptyList()) + } + + fun buildArticleFromNostrEvent( + feed: Feed, + accountId: Int, + articleEvent: Event, + authorName: String, +// imageUrl: String, + preDate: Date = Date() + ): Article { + val articleTitle = articleEvent.tags().find(TagKind.Title)?.content() + val articleImage = articleEvent.tags().find(TagKind.Image)?.content() + val articleSummary = articleEvent.tags().find(TagKind.Summary)?.content() + val timeStamp = articleEvent.tags().find(TagKind.PublishedAt)?.content()?.toLong() + ?: Instant.EPOCH.epochSecond + val articleDate = Date.from(Instant.ofEpochSecond(timeStamp)).takeIf { !it.isFuture(preDate) } ?: preDate + val articleNostrAddress = + Coordinate( + Kind.fromEnum(KindEnum.LongFormTextNote), + articleEvent.author(), + articleEvent.tags().find( + TagKind.SingleLetter( + SingleLetterTag.lowercase(Alphabet.D), + ), + )?.content().toString(), + ).toBech32() + // Highlighter is a service for reading Nostr articles on the web. + //For the external link, we can still give it a value of nostr: + val externalLink = "nostr:$articleNostrAddress"//""https://highlighter.com/a/$articleNostrAddress" + val articleContent = articleEvent.content() + val parsedContent = htmlFromMarkdown(articleContent) + val actualContent = Readability.parseToText( + parsedContent, + uri = null + ) + + Log.i( + "RLog", + "Nostr Feed:\n" + + "name: ${feed.name}\n" + + "feedUrl: ${feed.url}\n" + + "url: ${externalLink}\n" + + "title: ${articleTitle}\n" + + "desc: ${articleSummary}\n" + + "content: ${articleContent}\n" + ) + + return Article( + id = accountId.spacerDollar(UUID.randomUUID().toString()), + accountId = accountId, + feedId = feed.id, + date = articleDate, + title = articleTitle ?: feed.name, + author = authorName, + rawDescription = parsedContent, + shortDescription = articleSummary ?: actualContent.take(110), + fullContent = parsedContent, + img = articleImage, + link = externalLink, + updateAt = articleDate + ) + } + fun findThumbnail(syndEntry: SyndEntry): String? { if (syndEntry.enclosures?.firstOrNull()?.url != null) { return syndEntry.enclosures.first().url diff --git a/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt b/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt index 97a1a761d..4011a8200 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt @@ -2,6 +2,9 @@ package me.ash.reader.ui.ext import android.text.Html import android.util.Base64 +import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser import java.math.BigInteger import java.security.MessageDigest import java.text.Bidi @@ -18,7 +21,7 @@ fun String.formatUrl(): String { if (this.startsWith("//")) { return "https:$this" } - val regex = Regex("^(https?|ftp|file).*") + val regex = Regex("^(https?|ftp|file|nostr).*") return if (!regex.matches(this)) { "https://$this" } else { @@ -61,3 +64,16 @@ fun String?.extractDomain(): String? { val domainMatchResult = domainRegex.find(this) return domainMatchResult?.value } + +private val markDownParser = MarkdownParser(CommonMarkFlavourDescriptor()) + +fun htmlFromMarkdown(markdown: String): String { + val parsedMarkdown = markDownParser.buildMarkdownTreeFromString(markdown) + val htmlContent = HtmlGenerator(markdown, parsedMarkdown, CommonMarkFlavourDescriptor()) + .generateHtml() + + return htmlContent +} + +const val NOSTR_URI_PREFIX = "nostr:" +fun String.isNostrUri(): Boolean = startsWith(NOSTR_URI_PREFIX) && length > NOSTR_URI_PREFIX.length \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt index 3fe71c824..3adccc28a 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeDialog.kt @@ -9,7 +9,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.animation.with import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CreateNewFolder @@ -81,7 +80,7 @@ fun SubscribeDialog( icon = { FeedIcon( feedName = subscribeUiState.searchedFeed?.title ?: stringResource(R.string.subscribe), - iconUrl = subscribeUiState.searchedFeed?.icon?.url, + iconUrl = subscribeUiState.searchedFeed?.getIconUrl(), placeholderIcon = Icons.Rounded.RssFeed, ) }, diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index e5eec474a..fcdbaae8d 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -2,7 +2,6 @@ package me.ash.reader.ui.page.home.feeds.subscribe import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.rometools.rome.feed.synd.SyndFeed import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -20,6 +19,7 @@ import me.ash.reader.domain.service.OpmlService import me.ash.reader.domain.service.RssService import me.ash.reader.infrastructure.android.AndroidStringsHelper import me.ash.reader.infrastructure.di.ApplicationScope +import me.ash.reader.infrastructure.rss.FetchedFeed import me.ash.reader.infrastructure.rss.RssHelper import me.ash.reader.ui.ext.formatUrl import java.io.InputStream @@ -245,7 +245,7 @@ data class SubscribeUiState( val errorMessage: String = "", val linkContent: String = "", val lockLinkInput: Boolean = false, - val searchedFeed: SyndFeed? = null, + val searchedFeed: FetchedFeed? = null, val allowNotificationPreset: Boolean = false, val parseFullContentPreset: Boolean = false, val selectedGroupId: String = "", diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt index a84362529..d312078d3 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt @@ -22,6 +22,7 @@ import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.rss.RssHelper import me.ash.reader.infrastructure.storage.AndroidImageDownloader +import me.ash.reader.ui.ext.isNostrUri import java.util.Date import javax.inject.Inject @@ -96,10 +97,14 @@ class ReadingViewModel @Inject constructor( private suspend fun internalRenderFullContent() { setLoading() runCatching { - rssHelper.parseFullContent( - currentArticle?.link ?: "", - currentArticle?.title ?: "" - ) + if (currentArticle?.link?.isNostrUri() == true) { + currentArticle?.fullContent.toString() + } else { + rssHelper.parseFullContent( + currentArticle?.link ?: "", + currentArticle?.title ?: "" + ) + } }.onSuccess { content -> _readerState.update { it.copy(content = ReaderState.FullContent(content = content)) } }.onFailure { th -> diff --git a/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt b/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt index 6cfccb310..56998986d 100644 --- a/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt +++ b/app/src/test/java/me/ash/reader/infrastructure/rss/RssHelperTest.kt @@ -10,6 +10,7 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.mock +import rust.nostr.sdk.Client internal const val enclosureUrlString1: String = "https://example.com/enclosure.jpg" internal const val enclosureUrlString2: String = "https://github.blog/wp-content/uploads/2024/03/github_copilot_header.png" @@ -50,6 +51,9 @@ class RssHelperTest { @Mock private lateinit var mockOkHttpClient: OkHttpClient + @Mock + private lateinit var mockNostrClient: Client + private lateinit var rssHelper: RssHelper @Before @@ -57,7 +61,8 @@ class RssHelperTest { mockContext = mock { } mockIODispatcher = mock {} mockOkHttpClient = mock {} - rssHelper = RssHelper(mockContext, mockIODispatcher, mockOkHttpClient) + mockNostrClient = mock { } + rssHelper = RssHelper(mockContext, mockIODispatcher, mockOkHttpClient, mockNostrClient) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca38138c8..26e95f69a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,12 @@ hiltCompiler = "1.1.0" hiltNavigationCompose = "1.1.0" hiltWork = "1.1.0" +# Markdown +jetbrains-md = "0.7.3" + +# Nostr +rustnostr = "0.38.3" + # Miscellaneous Libraries androidSVG = "1.4" junit = "4.13.2" @@ -118,6 +124,11 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +# Markdown +jetbrains-markdown = { group = "org.jetbrains", name = "markdown", version.ref = "jetbrains-md" } +# Nostr +rust-nostr = { group = "org.rust-nostr", name = "nostr-sdk", version.ref = "rustnostr" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } junit-ext = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }