Rewrite Nextcloud News API implementation

* New data source
* New service
* Tests
This commit is contained in:
Shinokuni 2024-06-15 18:00:12 +02:00
parent 7a4d4d7225
commit b879d0ae9d
8 changed files with 581 additions and 26 deletions

View File

@ -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<Item> {
return service.getItems(type, read, batchSize)
}
suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List<Item> {
return service.getNewItems(lastModified, itemQueryType.value)
}
suspend fun createFeed(url: String, folderId: Int?): List<Feed> {
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<Folder> {
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<StarItem>) {
if (itemIds.isNotEmpty()) {
val body = arrayListOf<Map<String, String>>()
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
}
}

View File

@ -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<Folder>
@GET("feeds")
suspend fun getFeeds(): List<Feed>
@GET("items")
suspend fun getItems(
@Query("type") type: Int,
@Query("getRead") read: Boolean,
@Query("batchSize") batchSize: Int
): List<Item>
@GET("items/updated")
suspend fun getNewItems(
@Query("lastModified") lastModified: Long,
@Query("type") type: Int
): List<Item>
@PUT("items/{stateType}/multiple")
@JvmSuppressWildcards
suspend fun setReadState(
@Path("stateType") stateType: String,
@Body itemIdsMap: Map<String, List<Int>>
)
@PUT("items/{starType}/multiple")
@JvmSuppressWildcards
suspend fun setStarState(
@Path("starType") starType: String?,
@Body body: Map<String?, List<Map<String, String>>>
)
@POST("feeds")
suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List<Feed>
@DELETE("feeds/{feedId}")
suspend fun deleteFeed(@Path("feedId") feedId: Int)
@PUT("feeds/{feedId}/move")
suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int>)
@PUT("feeds/{feedId}/rename")
suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>)
@POST("folders")
suspend fun createFolder(@Body folderName: Map<String, String>): List<Folder>
@DELETE("folders/{folderId}")
suspend fun deleteFolder(@Path("folderId") folderId: Int)
@PUT("folders/{folderId}")
suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>)
companion object {
const val END_POINT = "/index.php/apps/news/api/v1-2/"
}
}

View File

@ -72,7 +72,7 @@ public class NextNewsDataSource {
return response.body(); 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(); SyncResult syncResult = new SyncResult();
switch (syncType) { switch (syncType) {
case INITIAL_SYNC: case INITIAL_SYNC:
@ -113,7 +113,7 @@ public class NextNewsDataSource {
syncResult.setStarredItems(starredItems); syncResult.setStarredItems(starredItems);
} }
private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException { private void classicSync(SyncResult syncResult, NextcloudNewsSyncData data) throws IOException {
putModifiedItems(data, syncResult); putModifiedItems(data, syncResult);
getFeedsAndFolders(syncResult); getFeedsAndFolders(syncResult);
@ -148,12 +148,12 @@ public class NextNewsDataSource {
} }
private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException { private void putModifiedItems(NextcloudNewsSyncData data, SyncResult syncResult) throws IOException {
setReadState(data.getReadItems(), syncResult, StateType.READ); /*setReadState(data.getReadIds(), syncResult, StateType.READ);
setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD); setReadState(data.getUnreadIds(), syncResult, StateType.UNREAD);
setStarState(data.getStarredItems(), syncResult, StateType.STAR); setStarState(data.getStarredIds(), syncResult, StateType.STAR);
setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR); setStarState(data.getUnstarredIds(), syncResult, StateType.UNSTAR);*/
} }
public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException { public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {

View File

@ -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<String> = listOf(),
var readItems: List<String> = listOf(),
var starredItems: List<StarItem> = listOf(),
var unstarredItems: List<StarItem> = listOf(),
)

View File

@ -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<Int> = listOf(),
val unreadIds: List<Int> = listOf(),
val starredIds: List<StarItem> = listOf(),
val unstarredIds: List<StarItem> = listOf(),
)

View File

@ -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)))
}

View File

@ -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<Map<String, Int>>(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<Map<String, String>>(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<Map<String, String>>(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<Map<String, String>>(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<Map<String, List<Int>>>(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<Map<String, List<Map<String, String>>>>(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"])
}
}

View File

@ -11,7 +11,7 @@ import androidx.annotation.Nullable;
import com.readrops.api.services.SyncResult; import com.readrops.api.services.SyncResult;
import com.readrops.api.services.SyncType; import com.readrops.api.services.SyncType;
import com.readrops.api.services.nextcloudnews.NextNewsDataSource; 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.api.utils.exceptions.UnknownFormatException;
import com.readrops.app.addfeed.FeedInsertionResult; import com.readrops.app.addfeed.FeedInsertionResult;
import com.readrops.app.addfeed.ParsingResult; import com.readrops.app.addfeed.ParsingResult;
@ -86,21 +86,21 @@ public class NextNewsRepository extends ARepository {
syncType = SyncType.INITIAL_SYNC; 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); syncData.setLastModified(account.getLastModified() / 1000L);
List<ItemReadStarState> itemStateChanges = database List<ItemReadStarState> itemStateChanges = database
.itemStateChangesDao() .itemStateChangesDao()
.getNextcloudNewsStateChanges(account.getId()); .getNextcloudNewsStateChanges(account.getId());
syncData.setReadItems(itemStateChanges.stream() syncData.setReadIds(itemStateChanges.stream()
.filter(it -> it.getReadChange() && it.getRead()) .filter(it -> it.getReadChange() && it.getRead())
.map(ItemReadStarState::getRemoteId) .map(ItemReadStarState::getRemoteId)
.collect(Collectors.toList())); .collect(Collectors.toList()));
syncData.setUnreadItems(itemStateChanges.stream() syncData.setUnreadIds(itemStateChanges.stream()
.filter(it -> it.getReadChange() && !it.getRead()) .filter(it -> it.getReadChange() && !it.getRead())
.map(ItemReadStarState::getRemoteId) .map(ItemReadStarState::getRemoteId)
.collect(Collectors.toList())); .collect(Collectors.toList()));
@ -111,7 +111,7 @@ public class NextNewsRepository extends ARepository {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (!starredItemsIds.isEmpty()) { if (!starredItemsIds.isEmpty()) {
syncData.setStarredItems(database.itemDao().getStarChanges(starredItemsIds, account.getId())); syncData.setStarredIds(database.itemDao().getStarChanges(starredItemsIds, account.getId()));
} }
List<String> unstarredItemsIds = itemStateChanges.stream() List<String> unstarredItemsIds = itemStateChanges.stream()
@ -120,10 +120,10 @@ public class NextNewsRepository extends ARepository {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (!unstarredItemsIds.isEmpty()) { 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()); TimingLogger timings = new TimingLogger(TAG, "nextcloud news " + syncType.name().toLowerCase());
SyncResult result = dataSource.sync(syncType, syncData); SyncResult result = dataSource.sync(syncType, syncData);