Make FeverRepository compile again and Fever auth work

This commit is contained in:
Shinokuni 2024-08-06 22:09:23 +02:00
parent 04820cd700
commit 732ae4efa4
15 changed files with 208 additions and 171 deletions

View File

@ -1,6 +1,7 @@
package com.readrops.api.services
import com.readrops.api.services.fever.FeverCredentials
import com.readrops.api.services.fever.FeverService
import com.readrops.api.services.freshrss.FreshRSSCredentials
import com.readrops.api.services.freshrss.FreshRSSService
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
@ -27,7 +28,7 @@ abstract class Credentials(val authorization: String?, val url: String) {
return when (accountType) {
AccountType.FRESHRSS -> FreshRSSService.END_POINT
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT
AccountType.FEVER -> ""
AccountType.FEVER -> FeverService.END_POINT
else -> throw IllegalArgumentException("Unknown account type")
}
}

View File

@ -2,35 +2,33 @@ package com.readrops.api.services.fever
import com.readrops.api.services.SyncType
import com.readrops.api.services.fever.adapters.FeverAPIAdapter
import com.readrops.api.utils.exceptions.LoginException
import com.readrops.api.utils.ApiUtils
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import okhttp3.MultipartBody
class FeverDataSource(private val service: FeverService) {
suspend fun login(body: MultipartBody) {
val response = service.login(body)
suspend fun login(login: String, password: String): Boolean {
val response = service.login(getFeverRequestBody(login, password))
val adapter = Moshi.Builder()
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
val authenticated = adapter.fromJson(response.source())!!
// Error handling is shit, but it will stay like that until the UI
// and the other data sources/repositories are rewritten in Kotlin
if (!authenticated) {
throw LoginException("Login failed. Please check your credentials")
}
return adapter.fromJson(response.source())!!
}
suspend fun sync(syncType: SyncType, syncData: FeverSyncData, body: MultipartBody): FeverSyncResult {
suspend fun synchronize(
syncType: SyncType,
syncData: FeverSyncData,
body: MultipartBody
): FeverSyncResult {
if (syncType == SyncType.INITIAL_SYNC) {
val unreadIds = service.getUnreadItemsIds(body)
.reversed()
.subList(0, MAX_ITEMS_IDS)
.reversed()
.subList(0, MAX_ITEMS_IDS)
var lastId = unreadIds.first()
val items = arrayListOf<Item>()
@ -42,13 +40,13 @@ class FeverDataSource(private val service: FeverService) {
}
return FeverSyncResult(
feverFeeds = service.getFeeds(body),
folders = service.getFolders(body),
items = items,
unreadIds = unreadIds,
starredIds = service.getStarredItemsIds(body),
favicons = listOf(),
sinceId = unreadIds.first().toLong(),
feverFeeds = service.getFeeds(body),
folders = service.getFolders(body),
items = items,
unreadIds = unreadIds,
starredIds = service.getStarredItemsIds(body),
favicons = listOf(),
sinceId = unreadIds.first().toLong(),
)
} else {
val items = arrayListOf<Item>()
@ -63,19 +61,31 @@ class FeverDataSource(private val service: FeverService) {
}
return FeverSyncResult(
feverFeeds = service.getFeeds(body),
folders = service.getFolders(body),
items = items,
unreadIds = service.getUnreadItemsIds(body),
starredIds = service.getStarredItemsIds(body),
favicons = listOf(),
sinceId = if (items.isNotEmpty()) items.first().remoteId!!.toLong() else sinceId.toLong(),
feverFeeds = service.getFeeds(body),
folders = service.getFolders(body),
items = items,
unreadIds = service.getUnreadItemsIds(body),
starredIds = service.getStarredItemsIds(body),
favicons = listOf(),
sinceId = if (items.isNotEmpty()) items.first().remoteId!!.toLong() else sinceId.toLong(),
)
}
}
suspend fun setItemState(body: MultipartBody, action: String, id: String) =
service.updateItemState(body, action, id)
suspend fun setItemState(login: String, password: String, action: String, id: String) {
val body = getFeverRequestBody(login, password)
service.updateItemState(body, action, id)
}
private fun getFeverRequestBody(login: String, password: String): MultipartBody {
val credentials = ApiUtils.md5hash("$login:$password")
return MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("api_key", credentials)
.build()
}
companion object {
private const val MAX_ITEMS_IDS = 5000
@ -85,12 +95,12 @@ class FeverDataSource(private val service: FeverService) {
sealed class ItemAction(val value: String) {
sealed class ReadStateAction(value: String) : ItemAction(value) {
object ReadAction : ReadStateAction("read")
object UnreadAction : ReadStateAction("unread")
data object ReadAction : ReadStateAction("read")
data object UnreadAction : ReadStateAction("unread")
}
sealed class StarStateAction(value: String) : ItemAction(value) {
object StarAction : StarStateAction("saved")
object UnstarAction : StarStateAction("unsaved")
data object StarAction : StarStateAction("saved")
data object UnstarAction : StarStateAction("unsaved")
}
}

View File

@ -38,4 +38,8 @@ interface FeverService {
suspend fun updateItemState(@Body body: MultipartBody, @Query("as") action: String,
@Query("id") id: String)
companion object {
const val END_POINT = "/api/fever.php/"
}
}

View File

@ -2,7 +2,13 @@ package com.readrops.api.services.fever.adapters
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.skipField
import com.squareup.moshi.*
import com.readrops.api.utils.extensions.toBoolean
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonReader.Token
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.ToJson
class FeverAPIAdapter : JsonAdapter<Boolean>() {
@ -15,19 +21,21 @@ class FeverAPIAdapter : JsonAdapter<Boolean>() {
override fun fromJson(reader: JsonReader): Boolean = with(reader) {
return try {
beginObject()
skipField()
var authenticated = 0
if (nextName() == "auth") {
authenticated = nextInt()
} else {
skipValue()
}
while (peek() == Token.NAME) {
skipField()
}
skipField()
endObject()
authenticated == 1
authenticated.toBoolean()
} catch (e: Exception) {
throw ParseException(e.message)
}

View File

@ -14,10 +14,6 @@ object ApiUtils {
const val LAST_MODIFIED_HEADER = "Last-Modified"
const val IF_MODIFIED_HEADER = "If-Modified-Since"
const val HTTP_UNPROCESSABLE = 422
const val HTTP_NOT_FOUND = 404
const val HTTP_CONFLICT = 409
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml")
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"

View File

@ -1,4 +0,0 @@
package com.readrops.api.utils.exceptions
class LoginException(override val message: String?) : Exception() {
}

View File

@ -0,0 +1,3 @@
package com.readrops.api.utils.exceptions
class LoginFailedException(override val message: String? = null) : Exception()

View File

@ -3,8 +3,9 @@ package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import okio.Buffer
import kotlin.test.Test
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class FeverAPIAdapterTest {
@ -20,4 +21,12 @@ class FeverAPIAdapterTest {
val value = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(value, true)
}
@Test
fun unauthenticatedTest() {
val stream = TestUtils.loadResource("services/fever/unsuccessful_auth.json")
val value = adapter.fromJson(Buffer().readFrom(stream))!!
assertFalse { value }
}
}

View File

@ -6,9 +6,8 @@ import com.readrops.api.services.fever.FeverCredentials
import com.readrops.api.services.fever.FeverDataSource
import com.readrops.api.utils.ApiUtils
import com.readrops.api.utils.AuthInterceptor
import com.readrops.api.utils.exceptions.LoginException
import com.readrops.api.utils.exceptions.LoginFailedException
import kotlinx.coroutines.runBlocking
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@ -68,9 +67,7 @@ class FeverDataSourceTest : KoinTest {
.setBody(Buffer().readFrom(stream)))
runBlocking {
dataSource.login(MultipartBody.Builder()
.addFormDataPart("api_key", "value")
.build())
dataSource.login("","")
}
}
@ -82,11 +79,9 @@ class FeverDataSourceTest : KoinTest {
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/json")
.setBody(Buffer().readFrom(stream)))
assertThrows(LoginException::class.java) {
assertThrows(LoginFailedException::class.java) {
runBlocking {
dataSource.login(MultipartBody.Builder()
.addFormDataPart("api_key", "value")
.build())
dataSource.login("","")
}
}
}

View File

@ -0,0 +1,4 @@
{
"api_version": 3,
"auth": 0
}

View File

@ -19,6 +19,7 @@ import com.readrops.app.item.ItemScreenModel
import com.readrops.app.more.preferences.PreferencesScreenModel
import com.readrops.app.notifications.NotificationsScreenModel
import com.readrops.app.repositories.BaseRepository
import com.readrops.app.repositories.FeverRepository
import com.readrops.app.repositories.FreshRSSRepository
import com.readrops.app.repositories.GetFoldersWithFeeds
import com.readrops.app.repositories.LocalRSSRepository
@ -61,13 +62,23 @@ val appModule = module {
when (account.accountType) {
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
AccountType.FRESHRSS -> FreshRSSRepository(
get(), account,
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
database = get(),
account = account,
dataSource = get(parameters = { parametersOf(Credentials.toCredentials(account)) })
)
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsRepository(
get(), account,
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
database = get(),
account = account,
dataSource = get(parameters = { parametersOf(Credentials.toCredentials(account)) })
)
AccountType.FEVER -> FeverRepository(
database = get(),
account = account,
feverDataSource = get(parameters = { parametersOf(Credentials.toCredentials(account)) })
)
else -> throw IllegalArgumentException("Unknown account type")
}
}
@ -91,7 +102,7 @@ val appModule = module {
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { emptyPreferences() }
),
migrations = listOf(SharedPreferencesMigration(get(),"settings")),
migrations = listOf(SharedPreferencesMigration(get(), "settings")),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { get<Context>().preferencesDataStoreFile("settings") }
)

View File

@ -41,6 +41,7 @@ import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.R
import com.readrops.app.account.selection.adaptiveIconPainterResource
import com.readrops.app.home.HomeScreen
import com.readrops.app.util.ErrorMessage
import com.readrops.app.util.components.AndroidScreen
@ -115,7 +116,7 @@ class AccountCredentialsScreen(
.verticalScroll(rememberScrollState())
) {
Image(
painter = painterResource(id = account.accountType!!.iconRes),
painter = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp)
)

View File

@ -1,7 +1,5 @@
package com.readrops.app.repositories
/*
import android.content.Context
import android.util.Log
import com.readrops.api.services.SyncType
import com.readrops.api.services.fever.FeverDataSource
@ -9,107 +7,103 @@ import com.readrops.api.services.fever.FeverSyncData
import com.readrops.api.services.fever.ItemAction
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.api.utils.ApiUtils
import com.readrops.app.addfeed.FeedInsertionResult
import com.readrops.app.addfeed.ParsingResult
import com.readrops.app.utils.Utils
import com.readrops.api.utils.exceptions.LoginFailedException
import com.readrops.app.util.Utils
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.ItemState
import com.readrops.db.entities.account.Account
import io.reactivex.Completable
import io.reactivex.Single
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.rx2.await
import kotlinx.coroutines.rx2.rxCompletable
import okhttp3.MultipartBody
class FeverRepository(
private val feverDataSource: FeverDataSource,
private val dispatcher: CoroutineDispatcher,
database: Database,
context: Context,
account: Account?,
) : BaseRepository(database, context, account) {
database: Database,
account: Account,
private val feverDataSource: FeverDataSource
) : BaseRepository(database, account) {
override fun login(account: Account, insert: Boolean): Completable =
rxCompletable(context = dispatcher) {
try {
feverDataSource.login(getFeverRequestBody())
account.displayedName = account.accountType!!.name
override suspend fun login(account: Account) {
val authenticated = feverDataSource.login(account.login!!, account.password!!)
database.accountDao().insert(account)
.doOnSuccess { account.id = it.toInt() }
.await()
} catch (e: Exception) {
Log.e(TAG, "login: ${e.message}")
error(e.message!!)
}
}
if (authenticated) {
account.displayedName = account.accountType!!.name
} else {
throw LoginFailedException()
}
}
override fun sync(feeds: List<Feed>?, update: FeedUpdate?): Completable =
rxCompletable(context = dispatcher) {
try {
val syncType = if (account.lastModified != 0L) {
SyncType.CLASSIC_SYNC
} else {
SyncType.INITIAL_SYNC
}
override suspend fun synchronize(): SyncResult {
val syncType = if (account.lastModified != 0L) {
SyncType.CLASSIC_SYNC
} else {
SyncType.INITIAL_SYNC
}
val syncResult = feverDataSource.sync(syncType,
FeverSyncData(account.lastModified.toString()), getFeverRequestBody())
return feverDataSource.synchronize(
syncType,
FeverSyncData(account.lastModified.toString()),
getFeverRequestBody()
).run {
insertFolders(folders)
insertFeeds(feverFeeds)
insertFolders(syncResult.folders)
insertFeeds(syncResult.feverFeeds)
insertItems(items)
insertItemsIds(unreadIds, starredIds.toMutableList())
insertItems(syncResult.items)
insertItemsIds(syncResult.unreadIds, syncResult.starredIds.toMutableList())
// We store the id to use for the next synchronisation even if it's not a timestamp
database.accountDao().updateLastModified(sinceId, account.id)
// We store the id to use for the next synchronisation even if it's not a timestamp
database.accountDao().updateLastModified(account.id, syncResult.sinceId)
} catch (e: Exception) {
Log.e(TAG, "sync: ${e.message}")
error(e.message!!)
}
}
SyncResult()
}
}
override suspend fun synchronize(
selectedFeeds: List<Feed>,
onUpdate: suspend (Feed) -> Unit
): Pair<SyncResult, ErrorResult> = throw NotImplementedError("This method can't be called here")
// Not supported by Fever API
override fun addFeeds(results: List<ParsingResult>?): Single<List<FeedInsertionResult>> = Single.just(listOf())
override suspend fun insertNewFeeds(
newFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): ErrorResult = throw CloneNotSupportedException()
// Not supported by Fever API
override fun updateFeed(feed: Feed?): Completable = Completable.complete()
override suspend fun updateFeed(feed: Feed) {}
// Not supported by Fever API
override fun deleteFeed(feed: Feed?): Completable = Completable.complete()
override suspend fun deleteFeed(feed: Feed) {}
// Not supported by Fever API
override fun addFolder(folder: Folder?): Single<Long> = Single.just(0)
override suspend fun addFolder(folder: Folder) {}
// Not supported by Fever API
override fun updateFolder(folder: Folder?): Completable = Completable.complete()
override suspend fun updateFolder(folder: Folder) {}
// Not supported by Fever API
override fun deleteFolder(folder: Folder?): Completable = Completable.complete()
override suspend fun deleteFolder(folder: Folder) {}
override fun setItemReadState(item: Item): Completable {
val action = if (item.isRead) ItemAction.ReadStateAction.ReadAction else ItemAction.ReadStateAction.UnreadAction
override suspend fun setItemReadState(item: Item) {
val action =
if (item.isRead) ItemAction.ReadStateAction.ReadAction else ItemAction.ReadStateAction.UnreadAction
return setItemState(item, action)
}
override fun setItemStarState(item: Item): Completable {
val action = if (item.isStarred) ItemAction.StarStateAction.StarAction else ItemAction.StarStateAction.UnstarAction
override suspend fun setItemStarState(item: Item) {
val action =
if (item.isStarred) ItemAction.StarStateAction.StarAction else ItemAction.StarStateAction.UnstarAction
return setItemState(item, action)
}
private fun setItemState(item: Item, action: ItemAction): Completable = rxCompletable(context = dispatcher) {
private suspend fun setItemState(item: Item, action: ItemAction) {
try {
feverDataSource.setItemState(getFeverRequestBody(), action.value, item.remoteId!!)
feverDataSource.setItemState(account.login!!, account.password!!, action.value, item.remoteId!!)
val itemState = ItemState(
read = item.isRead,
starred = item.isStarred,
remoteId = item.remoteId!!,
accountId = account.id,
read = item.isRead,
starred = item.isStarred,
remoteId = item.remoteId!!,
accountId = account.id,
)
val completable = if (action is ItemAction.ReadStateAction) {
@ -118,7 +112,6 @@ class FeverRepository(
database.itemStateDao().upsertItemStarState(itemState)
}
completable.await()
} catch (e: Exception) {
val completable = if (action is ItemAction.ReadStateAction) {
super.setItemReadState(item)
@ -126,14 +119,14 @@ class FeverRepository(
super.setItemStarState(item)
}
completable.await()
Log.e(TAG, "setItemStarState: ${e.message}")
error(e.message!!)
}
}
private suspend fun sendPreviousItemStateChanges() {
val stateChanges = database.itemStateChangesDao().getItemStateChanges(account.id)
val stateChanges = database.itemStateChangeDao()
.selectItemStateChanges(account.id)
for (stateChange in stateChanges) {
val action = if (stateChange.readChange) {
@ -142,28 +135,30 @@ class FeverRepository(
if (stateChange.starred) ItemAction.StarStateAction.StarAction else ItemAction.StarStateAction.UnstarAction
}
feverDataSource.setItemState(getFeverRequestBody(), action.value, stateChange.remoteId)
feverDataSource.setItemState(account.login!!, account.password!!, action.value, stateChange.remoteId)
}
}
private fun insertFolders(folders: List<Folder>) {
private suspend fun insertFolders(folders: List<Folder>) {
folders.forEach { it.accountId = account.id }
database.folderDao().foldersUpsert(folders, account)
database.folderDao().upsertFolders(folders, account)
}
private fun insertFeeds(feverFeeds: FeverFeeds) = with(feverFeeds) {
private suspend fun insertFeeds(feverFeeds: FeverFeeds) = with(feverFeeds) {
for (feed in feeds) {
for ((folderId, feedsIds) in feedsGroups) {
if (feedsIds.contains(feed.remoteId!!.toInt())) feed.remoteFolderId = folderId.toString()
if (feedsIds.contains(feed.remoteId!!.toInt())) {
feed.remoteFolderId = folderId.toString()
}
}
}
feeds.forEach { it.accountId = account.id }
database.feedDao().feedsUpsert(feeds, account)
database.feedDao().upsertFeeds(feeds, account)
}
private fun insertItems(items: List<Item>) {
val itemsToInsert = arrayListOf<Item>()
private suspend fun insertItems(items: List<Item>): List<Item> {
val newItems = arrayListOf<Item>()
val itemsFeedsIds = mutableMapOf<String, Int>()
for (item in items) {
@ -171,46 +166,50 @@ class FeverRepository(
if (itemsFeedsIds.containsKey(item.feedRemoteId)) {
feedId = itemsFeedsIds[item.feedRemoteId]
} else {
feedId = database.feedDao().getFeedIdByRemoteId(item.feedRemoteId!!, account.id)
itemsFeedsIds[item.feedRemoteId!!] = feedId
//feedId = database.feedDao().getFeedIdByRemoteId(item.feedRemoteId!!, account.id)
// itemsFeedsIds[item.feedRemoteId!!] = feedId
}
item.feedId = feedId!!
//item.feedId = feedId!!
item.text?.let { item.readTime = Utils.readTimeFromString(it) }
itemsToInsert += item
newItems += item
}
if (itemsToInsert.isNotEmpty()) {
itemsToInsert.sortWith(Item::compareTo)
database.itemDao().insert(itemsToInsert)
if (newItems.isNotEmpty()) {
newItems.sortWith(Item::compareTo)
database.itemDao().insert(newItems)
.zip(newItems)
.forEach { (id, item) -> item.id = id.toInt() }
}
return newItems
}
private fun insertItemsIds(unreadIds: List<String>, starredIds: MutableList<String>) {
database.itemStateDao().deleteItemsStates(account.id)
private suspend fun insertItemsIds(unreadIds: List<String>, starredIds: MutableList<String>) {
database.itemStateDao().deleteItemStates(account.id)
database.itemStateDao().insertItemStates(unreadIds.map { unreadId ->
database.itemStateDao().insert(unreadIds.map { unreadId ->
val starred = starredIds.any { starredId -> starredId == unreadId }
if (starred) starredIds.remove(unreadId)
ItemState(
id = 0,
read = false,
starred = starred,
remoteId = unreadId,
accountId = account.id,
id = 0,
read = false,
starred = starred,
remoteId = unreadId,
accountId = account.id,
)
})
if (starredIds.isNotEmpty()) {
database.itemStateDao().insertItemStates(starredIds.map { starredId ->
database.itemStateDao().insert(starredIds.map { starredId ->
ItemState(
id = 0,
read = true, // if this id wasn't in the unread ids list, it is considered a read
starred = true,
remoteId = starredId,
accountId = account.id,
id = 0,
read = true, // if this id wasn't in the unread ids list, it is considered a read
starred = true,
remoteId = starredId,
accountId = account.id,
)
})
}
@ -219,12 +218,12 @@ class FeverRepository(
private fun getFeverRequestBody(): MultipartBody {
val credentials = ApiUtils.md5hash("${account.login}:${account.password}")
return MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("api_key", credentials)
.build()
.setType(MultipartBody.FORM)
.addFormDataPart("api_key", credentials)
.build()
}
companion object {
val TAG: String = FeverRepository::class.java.simpleName
}
}*/
}

View File

@ -9,7 +9,7 @@ enum class AccountType(@DrawableRes val iconRes: Int,
val accountConfig: AccountConfig?) {
LOCAL(R.mipmap.ic_launcher, R.string.local_account, AccountConfig.LOCAL),
NEXTCLOUD_NEWS(R.drawable.ic_nextcloud_news, R.string.nextcloud_news, AccountConfig.NEXTCLOUD_NEWS),
FEEDLY(R.drawable.ic_feedly, R.string.feedly, null),
//FEEDLY(R.drawable.ic_feedly, R.string.feedly, null),
FRESHRSS(R.drawable.ic_freshrss, R.string.freshrss, AccountConfig.FRESHRSS),
FEVER(R.mipmap.ic_launcher, R.string.fever, AccountConfig.FEVER)
}

View File

@ -27,7 +27,7 @@ object FeedUnreadCountQueryBuilder {
)
} else {
SimpleSQLiteQuery(
"""Select feed_id, count(*) AS item_count From ItemState Inner Join Item On ItemState.remote_id = Item.remoteId
"""Select feed_id, count(*) AS item_count From ItemState Inner Join Item On ItemState.remote_id = Item.remote_id
Where ItemState.read = 0 And account_id = $accountId $filter Group By feed_id""".trimIndent())
}