diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsDataSource.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsDataSource.kt new file mode 100644 index 00000000..e962abc6 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsDataSource.kt @@ -0,0 +1,157 @@ +package com.readrops.api.services.nextcloudnews + +import com.gitlab.mvysny.konsumexml.konsumeXml +import com.readrops.api.services.SyncResult +import com.readrops.api.services.SyncType +import com.readrops.api.services.nextcloudnews.adapters.NextNewsUserAdapter +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import com.readrops.db.entities.account.Account +import com.readrops.db.pojo.StarItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import okhttp3.OkHttpClient +import okhttp3.Request + +class NewNextcloudNewsDataSource(private val service: NewNextcloudNewsService) { + + suspend fun login(client: OkHttpClient, account: Account): String { + val request = Request.Builder() + .url(account.url + "/ocs/v1.php/cloud/users/" + account.login) + .addHeader("OCS-APIRequest", "true") + .build() + + val response = client.newCall(request) + .execute() + + val displayName = NextNewsUserAdapter().fromXml(response.body!!.byteStream().konsumeXml()) + response.close() + + return displayName + } + + suspend fun synchronize(syncType: SyncType, syncData: NextcloudNewsSyncData): SyncResult = + with(CoroutineScope(Dispatchers.IO)) { + return if (syncType == SyncType.INITIAL_SYNC) { + SyncResult().apply { + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) }, + async { + starredItems = + getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS) + } + ).awaitAll() + } + } else { + listOf( + async { setItemsReadState(syncData) }, + async { setItemsStarState(StateType.STAR, syncData.starredIds) }, + async { setItemsStarState(StateType.UNSTAR, syncData.unstarredIds) } + ).awaitAll() + + SyncResult().apply { + listOf( + async { folders = getFolders() }, + async { feeds = getFeeds() }, + async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) } + ).awaitAll() + } + } + } + + suspend fun getFolders() = service.getFolders() + + suspend fun getFeeds() = service.getFeeds() + + suspend fun getItems(type: Int, read: Boolean, batchSize: Int): List { + return service.getItems(type, read, batchSize) + } + + suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List { + return service.getNewItems(lastModified, itemQueryType.value) + } + + suspend fun createFeed(url: String, folderId: Int?): List { + return service.createFeed(url, folderId) + } + + suspend fun changeFeedFolder(newFolderId: Int, feedId: Int) { + service.changeFeedFolder(feedId, mapOf("folderId" to newFolderId)) + } + + suspend fun renameFeed(name: String, folderId: Int) { + service.renameFeed(folderId, mapOf("feedTitle" to name)) + } + + suspend fun deleteFeed(feedId: Int) { + service.deleteFeed(feedId) + } + + suspend fun createFolder(name: String): List { + return service.createFolder(mapOf("name" to name)) + } + + suspend fun renameFolder(name: String, folderId: Int) { + service.renameFolder(folderId, mapOf("name" to name)) + } + + suspend fun deleteFolder(folderId: Int) { + service.deleteFolder(folderId) + } + + suspend fun setItemsReadState(syncData: NextcloudNewsSyncData) = with(syncData) { + if (unreadIds.isNotEmpty()) { + service.setReadState( + StateType.UNREAD.name.lowercase(), + mapOf("items" to unreadIds) + ) + } + + if (readIds.isNotEmpty()) { + service.setReadState( + StateType.READ.name.lowercase(), + mapOf("items" to readIds) + ) + } + } + + suspend fun setItemsStarState(stateType: StateType, itemIds: List) { + if (itemIds.isNotEmpty()) { + val body = arrayListOf>() + + for (item in itemIds) { + body += mapOf( + "feedId" to item.feedRemoteId, + "guidHash" to item.guidHash + ) + } + + service.setStarState( + stateType.name.lowercase(), + mapOf("items" to body) + ) + } + } + + enum class ItemQueryType(val value: Int) { + ALL(3), + STARRED(2) + } + + enum class StateType { + READ, + UNREAD, + STAR, + UNSTAR + } + + companion object { + private const val MAX_ITEMS = 5000 + private const val MAX_STARRED_ITEMS = 1000 + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsService.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsService.kt new file mode 100644 index 00000000..9990f482 --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NewNextcloudNewsService.kt @@ -0,0 +1,73 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.db.entities.Feed +import com.readrops.db.entities.Folder +import com.readrops.db.entities.Item +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface NewNextcloudNewsService { + + @GET("folders") + suspend fun getFolders(): List + + @GET("feeds") + suspend fun getFeeds(): List + + @GET("items") + suspend fun getItems( + @Query("type") type: Int, + @Query("getRead") read: Boolean, + @Query("batchSize") batchSize: Int + ): List + + @GET("items/updated") + suspend fun getNewItems( + @Query("lastModified") lastModified: Long, + @Query("type") type: Int + ): List + + @PUT("items/{stateType}/multiple") + @JvmSuppressWildcards + suspend fun setReadState( + @Path("stateType") stateType: String, + @Body itemIdsMap: Map> + ) + + @PUT("items/{starType}/multiple") + @JvmSuppressWildcards + suspend fun setStarState( + @Path("starType") starType: String?, + @Body body: Map>> + ) + + @POST("feeds") + suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List + + @DELETE("feeds/{feedId}") + suspend fun deleteFeed(@Path("feedId") feedId: Int) + + @PUT("feeds/{feedId}/move") + suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map) + + @PUT("feeds/{feedId}/rename") + suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map) + + @POST("folders") + suspend fun createFolder(@Body folderName: Map): List + + @DELETE("folders/{folderId}") + suspend fun deleteFolder(@Path("folderId") folderId: Int) + + @PUT("folders/{folderId}") + suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map) + + companion object { + const val END_POINT = "/index.php/apps/news/api/v1-2/" + } +} \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java index 43708b6f..de223680 100644 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsDataSource.java @@ -72,7 +72,7 @@ public class NextNewsDataSource { return response.body(); } - public SyncResult sync(@NonNull SyncType syncType, @Nullable NextNewsSyncData data) throws IOException { + public SyncResult sync(@NonNull SyncType syncType, @Nullable NextcloudNewsSyncData data) throws IOException { SyncResult syncResult = new SyncResult(); switch (syncType) { case INITIAL_SYNC: @@ -113,7 +113,7 @@ public class NextNewsDataSource { syncResult.setStarredItems(starredItems); } - private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException { + private void classicSync(SyncResult syncResult, NextcloudNewsSyncData data) throws IOException { putModifiedItems(data, syncResult); getFeedsAndFolders(syncResult); @@ -148,12 +148,12 @@ public class NextNewsDataSource { } - private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException { - setReadState(data.getReadItems(), syncResult, StateType.READ); - setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD); + private void putModifiedItems(NextcloudNewsSyncData data, SyncResult syncResult) throws IOException { + /*setReadState(data.getReadIds(), syncResult, StateType.READ); + setReadState(data.getUnreadIds(), syncResult, StateType.UNREAD); - setStarState(data.getStarredItems(), syncResult, StateType.STAR); - setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR); + setStarState(data.getStarredIds(), syncResult, StateType.STAR); + setStarState(data.getUnstarredIds(), syncResult, StateType.UNSTAR);*/ } public List createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException { diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt deleted file mode 100644 index 974d0154..00000000 --- a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextNewsSyncData.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.readrops.api.services.nextcloudnews - -import com.readrops.db.pojo.StarItem - -data class NextNewsSyncData( - var lastModified: Long = 0, - var unreadItems: List = listOf(), - var readItems: List = listOf(), - var starredItems: List = listOf(), - var unstarredItems: List = listOf(), -) \ No newline at end of file diff --git a/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt new file mode 100644 index 00000000..24a4b41b --- /dev/null +++ b/api/src/main/java/com/readrops/api/services/nextcloudnews/NextcloudNewsSyncData.kt @@ -0,0 +1,11 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.db.pojo.StarItem + +data class NextcloudNewsSyncData( + val lastModified: Long = 0, + val readIds: List = listOf(), + val unreadIds: List = listOf(), + val starredIds: List = listOf(), + val unstarredIds: List = listOf(), +) \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/MockServerExtensions.kt b/api/src/test/java/com/readrops/api/MockServerExtensions.kt new file mode 100644 index 00000000..057c0978 --- /dev/null +++ b/api/src/test/java/com/readrops/api/MockServerExtensions.kt @@ -0,0 +1,19 @@ +package com.readrops.api + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import java.io.InputStream +import java.net.HttpURLConnection + +fun MockWebServer.enqueueOK() { + enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + ) +} + +fun MockWebServer.enqueueStream(stream: InputStream) { + enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(Buffer().readFrom(stream))) +} \ No newline at end of file diff --git a/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt new file mode 100644 index 00000000..04d2f646 --- /dev/null +++ b/api/src/test/java/com/readrops/api/services/nextcloudnews/NextcloudNewsDataSourceTest.kt @@ -0,0 +1,306 @@ +package com.readrops.api.services.nextcloudnews + +import com.readrops.api.TestUtils +import com.readrops.api.apiModule +import com.readrops.api.enqueueOK +import com.readrops.api.enqueueStream +import com.readrops.api.services.nextcloudnews.NextNewsDataSource.ItemQueryType +import com.readrops.db.entities.account.Account +import com.readrops.db.pojo.StarItem +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.get +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NextcloudNewsDataSourceTest : KoinTest { + + private lateinit var nextcloudNewsDataSource: NewNextcloudNewsDataSource + private val mockServer = MockWebServer() + private val moshi = Moshi.Builder() + .build() + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(apiModule, module { + single { + Retrofit.Builder() + .baseUrl("http://localhost:8080/") + .client(get()) + .addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi")))) + .build() + .create(NewNextcloudNewsService::class.java) + } + }) + } + + @Before + fun before() { + mockServer.start(8080) + nextcloudNewsDataSource = NewNextcloudNewsDataSource(get()) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun loginTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/user.xml") + val account = Account(login = "login", url = mockServer.url("").toString()) + + mockServer.enqueueStream(stream) + + val displayName = nextcloudNewsDataSource.login(get(), account) + val request = mockServer.takeRequest() + + assertTrue { displayName == "Shinokuni" } + assertTrue { request.headers.contains("OCS-APIRequest" to "true") } + assertTrue { request.path == "//ocs/v1.php/cloud/users/login" } + } + + @Test + fun foldersTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") + mockServer.enqueueStream(stream) + + val folders = nextcloudNewsDataSource.getFolders() + assertTrue { folders.size == 1 } + } + + @Test + fun feedsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") + mockServer.enqueueStream(stream) + + val feeds = nextcloudNewsDataSource.getFeeds() + assertTrue { feeds.size == 3 } + } + + @Test + fun itemsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") + mockServer.enqueueStream(stream) + + val items = nextcloudNewsDataSource.getItems(ItemQueryType.ALL.value, false, 10) + val request = mockServer.takeRequest() + + assertTrue { items.size == 3 } + with(request.requestUrl!!) { + assertEquals("3", queryParameter("type")) + assertEquals("false", queryParameter("getRead")) + assertEquals("10", queryParameter("batchSize")) + } + } + + @Test + fun newItemsTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json") + mockServer.enqueueStream(stream) + + val items = + nextcloudNewsDataSource.getNewItems(1512, NewNextcloudNewsDataSource.ItemQueryType.ALL) + val request = mockServer.takeRequest() + + assertTrue { items.size == 3 } + with(request.requestUrl!!) { + assertEquals("1512", queryParameter("lastModified")) + assertEquals("3", queryParameter("type")) + } + } + + @Test + fun createFeedTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json") + mockServer.enqueueStream(stream) + + val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", null) + val request = mockServer.takeRequest() + + assertTrue { feeds.isNotEmpty() } + with(request.requestUrl!!) { + assertEquals("https://news.ycombinator.com/rss", queryParameter("url")) + assertEquals(null, queryParameter("folderId")) + } + } + + @Test + fun deleteFeedTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.deleteFeed(15) + val request = mockServer.takeRequest() + + assertTrue { request.path!!.endsWith("/15") } + } + + @Test + fun changeFeedFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.changeFeedFolder(15, 18) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Int::class.javaObjectType + ) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/18/move") } + assertEquals(15, body["folderId"]) + } + + @Test + fun renameFeedTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.renameFeed("name", 15) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/15/rename") } + assertEquals("name", body["feedTitle"]) + } + + @Test + fun createFolderTest() = runTest { + val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json") + mockServer.enqueueStream(stream) + + val folders = nextcloudNewsDataSource.createFolder("folder name") + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { folders.size == 1 } + assertEquals("folder name", body["name"]) + } + + @Test + fun renameFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.renameFolder("new name", 15) + val request = mockServer.takeRequest() + + val type = + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + val adapter = moshi.adapter>(type) + val body = adapter.fromJson(request.body)!! + + assertTrue { request.path!!.endsWith("/15") } + assertEquals("new name", body["name"]) + } + + @Test + fun deleteFolderTest() = runTest { + mockServer.enqueueOK() + + nextcloudNewsDataSource.deleteFolder(15) + val request = mockServer.takeRequest() + + assertEquals(request.method, "DELETE") + assertTrue { request.path!!.endsWith("/15") } + } + + @Test + fun setItemsReadStateTest() = runTest { + mockServer.enqueueOK() + mockServer.enqueueOK() + + val data = NextcloudNewsSyncData( + readIds = listOf(15, 16, 17), + unreadIds = listOf(18, 19, 20) + ) + + nextcloudNewsDataSource.setItemsReadState(data) + val unreadRequest = mockServer.takeRequest() + val readRequest = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(List::class.java, Int::class.javaObjectType) + ) + val adapter = moshi.adapter>>(type) + val unreadBody = adapter.fromJson(unreadRequest.body)!! + val readBody = adapter.fromJson(readRequest.body)!! + + assertEquals(data.readIds, readBody["items"]) + assertEquals(data.unreadIds, unreadBody["items"]) + } + + @Test + fun setItemsStarStateTest() = runTest { + mockServer.enqueueOK() + mockServer.enqueueOK() + + val starList = listOf( + StarItem("remote1", "guid1"), + StarItem("remote2", "guid2") + ) + nextcloudNewsDataSource.setItemsStarState( + NewNextcloudNewsDataSource.StateType.STAR, + starList + ) + + val starRequest = mockServer.takeRequest() + + val unstarList = listOf( + StarItem("remote3", "guid3"), + StarItem("remote4", "guid4") + ) + nextcloudNewsDataSource.setItemsStarState( + NewNextcloudNewsDataSource.StateType.UNSTAR, + unstarList + ) + + val unstarRequest = mockServer.takeRequest() + + val type = + Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType( + List::class.java, + Types.newParameterizedType( + Map::class.java, + String::class.java, + String::class.java + ) + ) + ) + val adapter = moshi.adapter>>>(type) + + val starBody = adapter.fromJson(starRequest.body)!! + val unstarBody = adapter.fromJson(unstarRequest.body)!! + + assertEquals(starList[0].feedRemoteId, starBody.values.first().first()["feedId"]) + assertEquals(unstarList[0].feedRemoteId, unstarBody.values.first().first()["feedId"]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java b/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java index 9860f0f2..436adf4b 100644 --- a/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java +++ b/app/src/main/java/com/readrops/app/repositories/NextNewsRepository.java @@ -11,7 +11,7 @@ import androidx.annotation.Nullable; import com.readrops.api.services.SyncResult; import com.readrops.api.services.SyncType; import com.readrops.api.services.nextcloudnews.NextNewsDataSource; -import com.readrops.api.services.nextcloudnews.NextNewsSyncData; +import com.readrops.api.services.nextcloudnews.NextcloudNewsSyncData; import com.readrops.api.utils.exceptions.UnknownFormatException; import com.readrops.app.addfeed.FeedInsertionResult; import com.readrops.app.addfeed.ParsingResult; @@ -86,21 +86,21 @@ public class NextNewsRepository extends ARepository { syncType = SyncType.INITIAL_SYNC; } - NextNewsSyncData syncData = new NextNewsSyncData(); + NextcloudNewsSyncData syncData = new NextcloudNewsSyncData(); - if (syncType == SyncType.CLASSIC_SYNC) { + /*if (syncType == SyncType.CLASSIC_SYNC) { syncData.setLastModified(account.getLastModified() / 1000L); List itemStateChanges = database .itemStateChangesDao() .getNextcloudNewsStateChanges(account.getId()); - syncData.setReadItems(itemStateChanges.stream() + syncData.setReadIds(itemStateChanges.stream() .filter(it -> it.getReadChange() && it.getRead()) .map(ItemReadStarState::getRemoteId) .collect(Collectors.toList())); - syncData.setUnreadItems(itemStateChanges.stream() + syncData.setUnreadIds(itemStateChanges.stream() .filter(it -> it.getReadChange() && !it.getRead()) .map(ItemReadStarState::getRemoteId) .collect(Collectors.toList())); @@ -111,7 +111,7 @@ public class NextNewsRepository extends ARepository { .collect(Collectors.toList()); if (!starredItemsIds.isEmpty()) { - syncData.setStarredItems(database.itemDao().getStarChanges(starredItemsIds, account.getId())); + syncData.setStarredIds(database.itemDao().getStarChanges(starredItemsIds, account.getId())); } List unstarredItemsIds = itemStateChanges.stream() @@ -120,10 +120,10 @@ public class NextNewsRepository extends ARepository { .collect(Collectors.toList()); if (!unstarredItemsIds.isEmpty()) { - syncData.setUnstarredItems(database.itemDao().getStarChanges(unstarredItemsIds, account.getId())); + syncData.setUnstarredIds(database.itemDao().getStarChanges(unstarredItemsIds, account.getId())); } - } + }*/ TimingLogger timings = new TimingLogger(TAG, "nextcloud news " + syncType.name().toLowerCase()); SyncResult result = dataSource.sync(syncType, syncData);