WIP. Everything isn't working yet, but api calls are made.

This commit is contained in:
aminecmi
2022-05-21 10:41:09 +02:00
parent bd4d20e858
commit 6e38e8753c
47 changed files with 1427 additions and 2112 deletions

View File

@ -0,0 +1,9 @@
package bou.amine.apps.readerforselfossv2.dao
interface DeviceDatabase<ItemEntity> {
suspend fun items(): List<ItemEntity>
suspend fun insertAllItems(vararg items: ItemEntity)
suspend fun deleteAllItems()
suspend fun delete(item: ItemEntity)
suspend fun updateItem(item: ItemEntity)
}

View File

@ -1,188 +1,229 @@
package bou.amine.apps.readerforselfossv2.rest
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.text.Html
import bou.amine.apps.readerforselfossv2.rest.SelfossModel.SelfossModel.constructUrl
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
import io.ktor.client.engine.ProxyBuilder.http
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmField
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class SelfossApi {
/**
* TODO:
* Self signed certs
* Timeout + 408
* Auth digest/basic
* Loging
*/
class SelfossApi(private val apiDetailsService: ApiDetailsService) {
val baseUrl = "http://10.0.2.2:8888"
val userName = ""
val password = ""
val client = HttpClient() {
install(JsonFeature) {
serializer = KotlinxSerializer(kotlinx.serialization.json.Json {
private val client = HttpClient() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(Logging) {
logger = object: Logger {
override fun log(message: String) {
apiDetailsService.logApiCalls(message)
}
}
level = LogLevel.ALL
}
/* TODO: Auth as basic
if (apiDetailsService.getUserName().isNotEmpty() && apiDetailsService.getPassword().isNotEmpty()) {
install(Auth) {
basic {
credentials {
BasicAuthCredentials(username = apiDetailsService.getUserName(), password = apiDetailsService.getPassword())
}
sendWithoutRequest {
true
}
}
}
}*/
expectSuccess = false
}
private fun url(path: String) =
"$baseUrl$path"
"${apiDetailsService.getBaseUrl()}$path"
suspend fun login() =
client.get<String>(url("/login"))// Todo: params
suspend fun login(): SelfossModel.SuccessResponse? =
client.get(url("/login")) {
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun getItems(type: String,
items: Int,
offset: Int,
tag: String? = "",
source: Long? = null,
search: String? = "",
updatedSince: String? = ""): List<SelfossModel.Item> =
suspend fun getItems(
type: String,
items: Int,
offset: Int,
tag: String? = "",
source: Long? = null,
search: String? = "",
updatedSince: String? = ""
): List<SelfossModel.Item>? =
client.get(url("/items")) {
parameter("username", userName)
parameter("password", password)
parameter("type", type)
parameter("tag", tag)
parameter("source", source)
parameter("search", search)
parameter("updatedsince", updatedSince)
parameter("items", items)
parameter("offset", offset)
}
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
parameter("type", type)
parameter("tag", tag)
parameter("source", source)
parameter("search", search)
parameter("updatedsince", updatedSince)
parameter("items", items)
parameter("offset", offset)
}.body()
suspend fun stats(): SelfossModel.Stats =
suspend fun stats(): SelfossModel.Stats? =
client.get(url("/stats")) {
parameter("username", userName)
parameter("password", password)
}
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun tags(): List<SelfossModel.Tag> =
suspend fun tags(): List<SelfossModel.Tag>? =
client.get(url("/tags")) {
parameter("username", userName)
parameter("password", password)
}
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun update(): String =
suspend fun update(): SelfossModel.SuccessResponse? =
client.get(url("/update")) {
parameter("username", userName)
parameter("password", password)
}
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun spouts(): Map<String, SelfossModel.Spout> =
client.get(url("/sources/spouts")) {
parameter("username", userName)
parameter("password", password)
}
suspend fun spouts(): Map<String, SelfossModel.Spout>? =
client.get(url("/a/spouts")) {
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun sources(): List<SelfossModel.Source> =
suspend fun sources(): ArrayList<SelfossModel.Source>? =
client.get(url("/sources/list")) {
parameter("username", userName)
parameter("password", password)
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
suspend fun version(): SelfossModel.ApiVersion? =
client.get(url("/api/about")).body()
suspend fun markAsRead(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/mark/$id"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
).body()
suspend fun unmarkAsRead(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/unmark/$id"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
).body()
suspend fun starr(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/starr/$id"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
).body()
suspend fun unstarr(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/unstarr/$id"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
).body()
suspend fun markAllAsRead(ids: List<String>): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/mark"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("ids[]", ids.joinToString(","))
},
encodeInQuery = true
).body()
suspend fun createSourceForVersion(
title: String,
url: String,
spout: String,
tags: String,
filter: String,
version: Int
): SelfossModel.SuccessResponse? =
if (version > 1) {
createSource(title, url, spout, tags, filter)
} else {
createSource2(title, url, spout, tags, filter)
}
suspend fun version(): SelfossModel.ApiVersion =
client.get(url("/api/about"))
suspend fun markAsRead(id: String): SelfossModel.SuccessResponse =
private suspend fun createSource(
title: String,
url: String,
spout: String,
tags: String,
filter: String
): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/mark/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
},
encodeInQuery = true
)
url = url("/source"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("title", title)
append("url", url)
append("spout", spout)
append("tags", tags)
append("filter", filter)
},
encodeInQuery = true
).body()
suspend fun unmarkAsRead(id: String): SelfossModel.SuccessResponse =
private suspend fun createSource2(
title: String,
url: String,
spout: String,
tags: String,
filter: String
): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/unmark/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
},
encodeInQuery = true
)
url = url("/source"),
formParameters = Parameters.build {
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("title", title)
append("url", url)
append("spout", spout)
append("tags[]", tags)
append("filter", filter)
},
encodeInQuery = true
).body()
suspend fun starr(id: String): SelfossModel.SuccessResponse =
client.submitForm(
url = url("/starr/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
},
encodeInQuery = true
)
suspend fun unstarr(id: String): SelfossModel.SuccessResponse =
client.submitForm(
url = url("/unstarr/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
},
encodeInQuery = true
)
suspend fun markAllAsRead(ids: List<String>): SelfossModel.SuccessResponse =
client.submitForm(
url = url("/mark"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("ids[]", ids.joinToString(","))
},
encodeInQuery = true
)
suspend fun createSource(title: String, url: String, spout: String, tags: String, filter: String): SelfossModel.SuccessResponse =
client.submitForm(
url = url("/source"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("title", title)
append("url", url)
append("spout", spout)
append("tags", tags)
append("filter", filter)
},
encodeInQuery = true
)
suspend fun createSource2(title: String, url: String, spout: String, tags: String, filter: String): SelfossModel.SuccessResponse =
client.submitForm(
url = url("/source"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("title", title)
append("url", url)
append("spout", spout)
append("tags[]", tags)
append("filter", filter)
},
encodeInQuery = true
)
suspend fun deleteSource(id: String) =
client.delete<SelfossModel.SuccessResponse>(url("/source/$id")) {
parameter("username", userName)
parameter("password", password)
}
suspend fun deleteSource(id: String): SelfossModel.SuccessResponse? =
client.delete(url("/source/$id")) {
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
}.body()
}

View File

@ -1,15 +1,8 @@
package bou.amine.apps.readerforselfossv2.rest
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import android.text.Html
import kotlinx.serialization.Serializable
import java.util.*
import kotlin.collections.ArrayList
import kotlin.jvm.JvmField
import org.jsoup.Jsoup
import java.util.Locale.US
class SelfossModel {
@ -61,19 +54,11 @@ class SelfossModel {
data class Source(
val id: String,
val title: String,
val tags: String,
val tags: List<String>,
val spout: String,
val error: String,
val icon: String
) {
fun getIcon(baseUrl: String): String {
return constructUrl(baseUrl, "favicons", icon)
}
fun getTitleDecoded(): String {
return Html.fromHtml(title).toString()
}
}
)
@Serializable
data class Item(
@ -88,81 +73,5 @@ class SelfossModel {
val link: String,
val sourcetitle: String,
val tags: String
) {
fun getIcon(baseUrl: String): String {
return constructUrl(baseUrl, "favicons", icon)
}
fun getThumbnail(baseUrl: String): String {
return constructUrl(baseUrl, "thumbnails", thumbnail)
}
fun getImages() : ArrayList<String> {
val allImages = ArrayList<String>()
for ( image in Jsoup.parse(content).getElementsByTag("img")) {
val url = image.attr("src")
if (url.lowercase(US).contains(".jpg") ||
url.lowercase(US).contains(".jpeg") ||
url.lowercase(US).contains(".png") ||
url.lowercase(US).contains(".webp"))
{
allImages.add(url)
}
}
return allImages
}
fun getTitleDecoded(): String {
return Html.fromHtml(title).toString()
}
fun getSourceTitle(): String {
return Html.fromHtml(sourcetitle).toString()
}
// TODO: maybe find a better way to handle these kind of urls
fun getLinkDecoded(): String {
var stringUrl: String
stringUrl =
if (link.startsWith("http://news.google.com/news/") || link.startsWith("https://news.google.com/news/")) {
if (link.contains("&amp;url=")) {
link.substringAfter("&amp;url=")
} else {
this.link.replace("&amp;", "&")
}
} else {
this.link.replace("&amp;", "&")
}
// handle :443 => https
if (stringUrl.contains(":443")) {
stringUrl = stringUrl.replace(":443", "").replace("http://", "https://")
}
// handle url not starting with http
if (stringUrl.startsWith("//")) {
stringUrl = "http:$stringUrl"
}
return stringUrl
}
}
companion object SelfossModel {
private fun String?.isEmptyOrNullOrNullString(): Boolean =
this == null || this == "null" || this.isEmpty()
fun constructUrl(baseUrl: String, path: String, file: String?): String {
return if (file.isEmptyOrNullOrNullString()) {
""
} else {
val baseUriBuilder = Uri.parse(baseUrl).buildUpon()
baseUriBuilder.appendPath(path).appendPath(file)
baseUriBuilder.toString()
}
}
}
)
}

View File

@ -0,0 +1,9 @@
package bou.amine.apps.readerforselfossv2.service
interface ApiDetailsService {
fun logApiCalls(message: String)
fun getApiVersion(): Int
fun getBaseUrl(): String
fun getUserName(): String
fun getPassword(): String
}

View File

@ -0,0 +1,34 @@
package bou.amine.apps.readerforselfossv2.service
import bou.amine.apps.readerforselfossv2.dao.DeviceDatabase
import bou.amine.apps.readerforselfossv2.utils.parseDate
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
abstract class DeviceDataBaseService<ItemEntity>(val db: DeviceDatabase<ItemEntity>, private val searchService: SearchService) {
var itemsCaching = false
var items: ArrayList<SelfossModel.Item> = arrayListOf()
get() {
return ArrayList(field)
}
set(value) {
field = ArrayList(value)
}
abstract suspend fun updateDatabase()
abstract suspend fun clearDBItems()
abstract fun appendNewItems(items: List<SelfossModel.Item>)
abstract fun getFromDB()
fun sortItems() {
val tmpItems = ArrayList(items.sortedByDescending { it.parseDate(searchService.dateUtils) })
items = tmpItems
}
// This filtered items from items val. Do not use
fun getFocusedItems() {}
fun computeBadges() {
searchService.badgeUnread = items.filter { item -> item.unread == 1 }.size
searchService.badgeStarred = items.filter { item -> item.starred == 1 }.size
searchService.badgeAll = items.size
}
}

View File

@ -0,0 +1,31 @@
package bou.amine.apps.readerforselfossv2.service
import bou.amine.apps.readerforselfossv2.utils.DateUtils
class SearchService(val dateUtils: DateUtils) {
var displayedItems: String = "unread"
set(value) {
field = when (value) {
"all" -> "all"
"unread" -> "unread"
"read" -> "read"
"starred" -> "starred"
else -> "all"
}
}
var position = 0
var searchFilter: String? = null
var sourceIDFilter: Long? = null
var sourceFilter: String? = null
var tagFilter: String? = null
var itemsCaching = false
var fetchedUnread = false
var fetchedAll = false
var fetchedStarred = false
var badgeUnread = -1
var badgeAll = -1
var badgeStarred = -1
}

View File

@ -0,0 +1,152 @@
package bou.amine.apps.readerforselfossv2.service
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import kotlinx.coroutines.*
class SelfossService<ItemEntity>(val api: SelfossApi, private val dbService: DeviceDataBaseService<ItemEntity>, private val searchService: SearchService) {
suspend fun getAndStoreAllItems(isNetworkAvailable: Boolean) = withContext(
Dispatchers.Default) {
if (isNetworkAvailable) {
launch {
try {
enqueueArticles(allNewItems(), true)
} catch (e: Throwable) {}
}
launch {
try {
enqueueArticles(allReadItems(), false)
} catch (e: Throwable) {}
}
launch {
try {
enqueueArticles(allStarredItems(), false)
} catch (e: Throwable) {}
}
} else {
launch { dbService.updateDatabase() }
}
}
suspend fun refreshFocusedItems(itemsNumber: Int, isNetworkAvailable: Boolean) = withContext(
Dispatchers.Default) {
if (isNetworkAvailable) {
val response = when (searchService.displayedItems) {
"read" -> readItems(itemsNumber, 0)
"unread" -> newItems(itemsNumber, 0)
"starred" -> starredItems(itemsNumber, 0)
else -> readItems(itemsNumber, 0)
}
if (response != null) {
// TODO:
// dbService.refreshFocusedItems(response.body() as ArrayList<SelfossModel.Item>)
dbService.updateDatabase()
}
}
}
suspend fun getReadItems(itemsNumber: Int, offset: Int, isNetworkAvailable: Boolean) = withContext(
Dispatchers.Default) {
if (isNetworkAvailable) {
try {
enqueueArticles(readItems( itemsNumber, offset), false)
searchService.fetchedAll = true
dbService.updateDatabase()
} catch (e: Throwable) {}
}
}
suspend fun getUnreadItems(itemsNumber: Int, offset: Int, isNetworkAvailable: Boolean) = withContext(
Dispatchers.Default) {
if (isNetworkAvailable) {
try {
if (!searchService.fetchedUnread) {
dbService.clearDBItems()
}
enqueueArticles(newItems(itemsNumber, offset), false)
searchService.fetchedUnread = true
} catch (e: Throwable) {}
}
dbService.updateDatabase()
}
suspend fun getStarredItems(itemsNumber: Int, offset: Int, isNetworkAvailable: Boolean) = withContext(
Dispatchers.Default) {
if (isNetworkAvailable) {
try {
enqueueArticles(starredItems(itemsNumber, offset), false)
searchService.fetchedStarred = true
dbService.updateDatabase()
} catch (e: Throwable) {
}
}
}
suspend fun readAll(isNetworkAvailable: Boolean): Boolean {
var success = false
if (isNetworkAvailable) {
// Do api call to read all
} else {
// Do db call to read all
}
// refresh view
return success
}
suspend fun reloadBadges(isNetworkAvailable: Boolean) = withContext(Dispatchers.Default) {
if (isNetworkAvailable) {
try {
val response = api.stats()
if (response != null) {
searchService.badgeUnread = response.unread
searchService.badgeAll = response.total
searchService.badgeStarred = response.starred
}
} catch (e: Throwable) {}
} else {
dbService.computeBadges()
}
}
private fun enqueueArticles(response: List<SelfossModel.Item>?, clearDatabase: Boolean) {
if (response != null) {
if (clearDatabase) {
CoroutineScope(Dispatchers.Default).launch {
dbService.clearDBItems()
}
}
dbService.appendNewItems(response)
}
}
private suspend fun allNewItems(): List<SelfossModel.Item>? =
readItems(200, 0)
private suspend fun allReadItems(): List<SelfossModel.Item>? =
newItems(200, 0)
private suspend fun allStarredItems(): List<SelfossModel.Item>? =
starredItems(200, 0)
private suspend fun readItems(
itemsNumber: Int,
offset: Int
): List<SelfossModel.Item>? =
api.getItems("read", itemsNumber, offset, searchService.tagFilter, searchService.sourceIDFilter, searchService.searchFilter)
private suspend fun newItems(
itemsNumber: Int,
offset: Int
): List<SelfossModel.Item>? =
api.getItems("unread", itemsNumber, offset, searchService.tagFilter, searchService.sourceIDFilter, searchService.searchFilter)
private suspend fun starredItems(
itemsNumber: Int,
offset: Int
): List<SelfossModel.Item>? =
api.getItems("starred", itemsNumber, offset, searchService.tagFilter, searchService.sourceIDFilter, searchService.searchFilter)
}

View File

@ -0,0 +1,41 @@
package bou.amine.apps.readerforselfossv2.utils
import android.text.format.DateUtils
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
fun SelfossModel.Item.parseDate(dateUtils: bou.amine.apps.readerforselfossv2.utils.DateUtils): Instant =
dateUtils.parseDate(this.datetime)
fun SelfossModel.Item.parseRelativeDate(dateUtils: bou.amine.apps.readerforselfossv2.utils.DateUtils): String =
dateUtils.parseRelativeDate(this.datetime)
class DateUtils(private val apiDetailsService: ApiDetailsService) {
fun parseDate(dateString: String): Instant {
val FORMATTERV1 = "yyyy-MM-dd HH:mm:ss"
return if (apiDetailsService.getApiVersion() >= 4) {
OffsetDateTime.parse(dateString).toInstant()
} else {
LocalDateTime.parse(dateString, DateTimeFormatter.ofPattern(FORMATTERV1)).toInstant(ZoneOffset.UTC)
}
}
fun parseRelativeDate(dateString: String): String {
val date = parseDate(dateString)
return " " + DateUtils.getRelativeTimeSpanString(
date.toEpochMilli(),
Instant.now().toEpochMilli(),
60000L, // DateUtils.MINUTE_IN_MILLIS,
262144 // DateUtils.FORMAT_ABBREV_RELATIVE
)
}
}

View File

@ -0,0 +1,22 @@
package bou.amine.apps.readerforselfossv2.utils
fun String?.isEmptyOrNullOrNullString(): Boolean =
this == null || this == "null" || this.isEmpty()
fun String.longHash(): Long {
var h = 98764321261L
val l = this.length
val chars = this.toCharArray()
for (i in 0 until l) {
h = 31 * h + chars[i].code.toLong()
}
return h
}
fun String.toStringUriWithHttp(): String =
if (!this.startsWith("https://") && !this.startsWith("http://")) {
"http://" + this
} else {
this
}