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

5
BREAKING.md Normal file
View File

@ -0,0 +1,5 @@
# Breaking changes
These breaking changes will **NOT** be handled in this version.
- Basic (kinda) and Digest auth. This version will use the selfoss credentials via Basic authentication.

10
TODO.md Normal file
View File

@ -0,0 +1,10 @@
# TODO
- Service injection
- Basic auth
* Self signed certs
* Timeout + 408
- Clean HTTP login

View File

@ -14,9 +14,6 @@ import android.widget.ProgressBar
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Spout
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.themes.Toppings
import bou.amine.apps.readerforselfossv2.android.utils.Config
@ -26,10 +23,19 @@ import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityAddSourceBinding
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AddSourceActivity : AppCompatActivity() {
private lateinit var apiDetailsService: ApiDetailsService
private var mSpoutsValue: String? = null
private lateinit var api: SelfossApi
@ -74,11 +80,13 @@ class AddSourceActivity : AppCompatActivity() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val settings =
getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE)
apiDetailsService = AndroidApiDetailsService(this@AddSourceActivity)
api = SelfossApi(
this,
this@AddSourceActivity,
settings.getBoolean("isSelfSignedCert", false),
prefs.getString("api_timeout", "-1")!!.toLong()
// this,
// this@AddSourceActivity,
// settings.getBoolean("isSelfSignedCert", false),
// prefs.getString("api_timeout", "-1")!!.toLong()
apiDetailsService
)
} catch (e: IllegalArgumentException) {
mustLoginToAddSource()
@ -124,14 +132,10 @@ class AddSourceActivity : AppCompatActivity() {
}
}
var items: Map<String, Spout>
api!!.spouts().enqueue(object : Callback<Map<String, Spout>> {
override fun onResponse(
call: Call<Map<String, Spout>>,
response: Response<Map<String, Spout>>
) {
if (response.body() != null) {
items = response.body()!!
CoroutineScope(Dispatchers.IO).launch {
var items = api!!.spouts()
if (items != null) {
val itemsStrings = items.map { it.value.name }
for ((key, value) in items) {
@ -150,15 +154,6 @@ class AddSourceActivity : AppCompatActivity() {
spinnerArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spoutsSpinner.adapter = spinnerArrayAdapter
} else {
handleProblemWithSpouts()
}
}
override fun onFailure(call: Call<Map<String, Spout>>, t: Throwable) {
handleProblemWithSpouts()
}
private fun handleProblemWithSpouts() {
Toast.makeText(
this@AddSourceActivity,
R.string.cant_get_spouts,
@ -166,7 +161,7 @@ class AddSourceActivity : AppCompatActivity() {
).show()
mProgress.visibility = View.GONE
}
})
}
}
private fun maybeGetDetailsFromIntentSharing(
@ -196,52 +191,17 @@ class AddSourceActivity : AppCompatActivity() {
sourceDetailsUnavailable -> {
Toast.makeText(this, R.string.form_not_complete, Toast.LENGTH_SHORT).show()
}
PreferenceManager.getDefaultSharedPreferences(this).getInt("apiVersionMajor", 0) > 1 -> {
val tagList = tags.text.toString().split(",").map { it.trim() }
api.createSourceApi2(
title,
url,
mSpoutsValue!!,
tagList,
""
).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
if (response.body() != null && response.body()!!.isSuccess) {
finish()
} else {
Toast.makeText(
this@AddSourceActivity,
R.string.cant_create_source,
Toast.LENGTH_SHORT
).show()
}
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
Toast.makeText(
this@AddSourceActivity,
R.string.cant_create_source,
Toast.LENGTH_SHORT
).show()
}
})
}
else -> {
api.createSource(
CoroutineScope(Dispatchers.IO).launch {
val response: SelfossModel.SuccessResponse? = api.createSourceForVersion(
title,
url,
mSpoutsValue!!,
tags.text.toString(),
""
).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
if (response.body() != null && response.body()!!.isSuccess) {
"",
PreferenceManager.getDefaultSharedPreferences(this@AddSourceActivity).getInt("apiVersionMajor", 0)
)
if (response != null) {
finish()
} else {
Toast.makeText(
@ -251,15 +211,6 @@ class AddSourceActivity : AppCompatActivity() {
).show()
}
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
Toast.makeText(
this@AddSourceActivity,
R.string.cant_create_source,
Toast.LENGTH_SHORT
).show()
}
})
}
}
}

View File

@ -29,27 +29,37 @@ import androidx.work.WorkManager
import bou.amine.apps.readerforselfossv2.android.adapters.ItemCardAdapter
import bou.amine.apps.readerforselfossv2.android.adapters.ItemListAdapter
import bou.amine.apps.readerforselfossv2.android.adapters.ItemsAdapter
import bou.amine.apps.readerforselfossv2.android.api.selfoss.*
import bou.amine.apps.readerforselfossv2.android.background.LoadingWorker
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityHomeBinding
import bou.amine.apps.readerforselfossv2.android.model.getIcon
import bou.amine.apps.readerforselfossv2.android.model.getTitleDecoded
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabaseService
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.settings.SettingsActivity
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.themes.Toppings
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.bottombar.maybeShow
import bou.amine.apps.readerforselfossv2.android.utils.bottombar.removeBadge
import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper
import bou.amine.apps.readerforselfossv2.android.utils.longHash
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.android.utils.persistence.toEntity
import bou.amine.apps.readerforselfossv2.android.utils.persistence.toView
import bou.amine.apps.readerforselfossv2.android.api.selfoss.*
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.service.SelfossService
import bou.amine.apps.readerforselfossv2.utils.longHash
import com.ashokvarma.bottomnavigation.BottomNavigationBar
import com.ashokvarma.bottomnavigation.BottomNavigationItem
import com.ashokvarma.bottomnavigation.TextBadgeItem
@ -73,14 +83,16 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private lateinit var dataBase: AndroidDeviceDatabase
private lateinit var dbService: AndroidDeviceDatabaseService
private lateinit var searchService: SearchService
private lateinit var apiDetailsService: ApiDetailsService
private lateinit var service: SelfossService<AndroidItemEntity>
private val MENU_PREFERENCES = 12302
private val DRAWER_ID_TAGS = 100101L
private val DRAWER_ID_HIDDEN_TAGS = 101100L
@ -90,8 +102,8 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private val READ_SHOWN = 2
private val FAV_SHOWN = 3
private var items: ArrayList<Item> = ArrayList()
private var allItems: ArrayList<Item> = ArrayList()
private var items: ArrayList<SelfossModel.Item> = ArrayList()
private var allItems: ArrayList<SelfossModel.Item> = ArrayList()
private var internalBrowser = false
private var articleViewer = false
@ -105,7 +117,6 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private var displayAccountHeader: Boolean = false
private var infiniteScroll: Boolean = false
private var lastFetchDone: Boolean = false
private var itemsCaching: Boolean = false
private var updateSources: Boolean = true
private var markOnScroll: Boolean = false
private var hiddenTags: List<String> = emptyList()
@ -140,7 +151,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private lateinit var config: Config
data class DrawerData(val tags: List<Tag>?, val sources: List<Source>?)
data class DrawerData(val tags: List<SelfossModel.Tag>?, val sources: List<SelfossModel.Source>?)
override fun onStart() {
super.onStart()
@ -184,12 +195,19 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
sharedPref = PreferenceManager.getDefaultSharedPreferences(this)
settings = getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE)
apiDetailsService = AndroidApiDetailsService(applicationContext)
api = SelfossApi(
this,
this@HomeActivity,
settings.getBoolean("isSelfSignedCert", false),
sharedPref.getString("api_timeout", "-1")!!.toLong()
// this,
// this@HomeActivity,
// settings.getBoolean("isSelfSignedCert", false),
// sharedPref.getString("api_timeout", "-1")!!.toLong()
apiDetailsService
)
dataBase = AndroidDeviceDatabase(applicationContext)
searchService = SearchService(DateUtils(apiDetailsService))
dbService = AndroidDeviceDatabaseService(dataBase, searchService)
service = SelfossService(api, dbService, searchService)
items = ArrayList()
allItems = ArrayList()
@ -217,7 +235,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
lastFetchDone = false
handleDrawerItems()
CoroutineScope(Dispatchers.Main).launch {
refreshFocusedItems(applicationContext, api, db, itemsNumber)
service.refreshFocusedItems(itemsNumber, applicationContext.isNetworkAvailable())
getElementsAccordingToTab()
binding.swipeRefreshLayout.isRefreshing = false
}
@ -258,7 +276,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
reloadBadgeContent()
val tagHashes = i.tags.tags.split(",").map { it.longHash() }
val tagHashes = i.tags.split(",").map { it.longHash() }
tagsBadge = tagsBadge.map {
if (tagHashes.contains(it.key)) {
(it.key to (it.value - 1))
@ -334,21 +352,13 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
private fun getApiMajorVersion() {
api.apiVersion.enqueue(object : Callback<ApiVersion> {
override fun onFailure(call: Call<ApiVersion>, t: Throwable) {
Config.apiVersion = apiVersionMajor
}
override fun onResponse(call: Call<ApiVersion>, response: Response<ApiVersion>) {
if(response.body() != null) {
val version = response.body() as ApiVersion
CoroutineScope(Dispatchers.IO).launch {
val version = api.version()
if (version != null) {
apiVersionMajor = version.getApiMajorVersion()
sharedPref.edit().putInt("apiVersionMajor", apiVersionMajor).apply()
Config.apiVersion = apiVersionMajor
}
}
})
}
override fun onResume() {
@ -382,17 +392,6 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
getElementsAccordingToTab()
}
private fun getAndStoreAllItems() {
CoroutineScope(Dispatchers.Main).launch {
binding.swipeRefreshLayout.isRefreshing = true
getAndStoreAllItems(applicationContext ,api, db)
this@HomeActivity.isNetworkAccessible(this@HomeActivity.findViewById(R.id.coordLayout), offlineShortcut)
handleListResult()
binding.swipeRefreshLayout.isRefreshing = false
SharedItems.updateDatabase(db)
}
}
override fun onStop() {
super.onStop()
customTabActivityHelper.unbindCustomTabsService(this)
@ -409,8 +408,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
userIdentifier = sharedPref.getString("unique_id", "")!!
displayAccountHeader = sharedPref.getBoolean("account_header_displaying", false)
infiniteScroll = sharedPref.getBoolean("infinite_loading", false)
itemsCaching = sharedPref.getBoolean("items_caching", false)
SharedItems.itemsCaching = itemsCaching
searchService.itemsCaching = sharedPref.getBoolean("items_caching", false)
updateSources = sharedPref.getBoolean("update_sources", true)
markOnScroll = sharedPref.getBoolean("mark_on_scroll", false)
hiddenTags = if (sharedPref.getString("hidden_tags", "")!!.isNotEmpty()) {
@ -525,7 +523,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun handleDrawerItems() {
tagsBadge = emptyMap()
fun handleDrawerData(maybeDrawerData: DrawerData?, loadedFromCache: Boolean = false) {
fun handleTags(maybeTags: List<Tag>?) {
fun handleTags(maybeTags: List<SelfossModel.Tag>?) {
if (maybeTags == null) {
if (loadedFromCache) {
binding.mainDrawer.itemAdapter.add(
@ -560,9 +558,9 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
color = ColorHolder.fromColor(appColors.colorAccent) }
onDrawerItemClickListener = { _,_,_ ->
allItems = ArrayList()
SharedItems.tagFilter = it.tag
SharedItems.sourceFilter = null
SharedItems.sourceIDFilter = null
searchService.tagFilter = it.tag
searchService.sourceFilter = null
searchService.sourceIDFilter = null
getElementsAccordingToTab()
fetchOnEmptyList()
false
@ -578,7 +576,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
}
fun handleHiddenTags(maybeTags: List<Tag>?) {
fun handleHiddenTags(maybeTags: List<SelfossModel.Tag>?) {
if (maybeTags == null) {
if (loadedFromCache) {
binding.mainDrawer.itemAdapter.add(
@ -589,7 +587,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
)
}
} else {
val filteredHiddenTags: List<Tag> =
val filteredHiddenTags: List<SelfossModel.Tag> =
maybeTags.filter { hiddenTags.contains(it.tag) }
tagsBadge = filteredHiddenTags.map {
val gd = GradientDrawable()
@ -613,9 +611,9 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
color = ColorHolder.fromColor(appColors.colorAccent) }
onDrawerItemClickListener = { _,_,_ ->
allItems = ArrayList()
SharedItems.tagFilter = it.tag
SharedItems.sourceFilter = null
SharedItems.sourceIDFilter = null
searchService.tagFilter = it.tag
searchService.sourceFilter = null
searchService.sourceIDFilter = null
getElementsAccordingToTab()
fetchOnEmptyList()
false
@ -631,7 +629,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
}
fun handleSources(maybeSources: List<Source>?) {
fun handleSources(maybeSources: List<SelfossModel.Source>?) {
if (maybeSources == null) {
if (loadedFromCache) {
binding.mainDrawer.itemAdapter.add(
@ -646,12 +644,12 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
val item = PrimaryDrawerItem().apply {
nameText = source.getTitleDecoded()
identifier = source.id.toLong()
iconUrl = source.getIcon(this@HomeActivity)
iconUrl = source.getIcon(apiDetailsService.getBaseUrl())
onDrawerItemClickListener = { _,_,_ ->
allItems = ArrayList()
SharedItems.sourceIDFilter = source.id.toLong()
SharedItems.sourceFilter = source.title
SharedItems.tagFilter = null
searchService.sourceIDFilter = source.id.toLong()
searchService.sourceFilter = source.title
searchService.tagFilter = null
getElementsAccordingToTab()
fetchOnEmptyList()
false
@ -672,9 +670,9 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
badgeRes = R.string.drawer_action_clear
onDrawerItemClickListener = { _,_,_ ->
allItems = ArrayList()
SharedItems.sourceFilter = null
SharedItems.sourceIDFilter = null
SharedItems.tagFilter = null
searchService.sourceFilter = null
searchService.sourceIDFilter = null
searchService.tagFilter = null
binding.mainDrawer.setSelectionAtPosition(-1)
getElementsAccordingToTab()
fetchOnEmptyList()
@ -769,47 +767,37 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
fun drawerApiCalls(maybeDrawerData: DrawerData?) {
var tags: List<Tag>? = null
var sources: List<Source>?
var tags: List<SelfossModel.Tag>? = null
var sources: List<SelfossModel.Source>?
fun sourcesApiCall() {
if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut) && updateSources) {
api.sources.enqueue(object : Callback<List<Source>> {
override fun onResponse(
call: Call<List<Source>>?,
response: Response<List<Source>>
) {
sources = response.body()
if (this@HomeActivity.isNetworkAvailable(null, offlineShortcut) && updateSources) {
CoroutineScope(Dispatchers.Main).launch {
val response = api.sources()
if (response != null) {
sources = response
val apiDrawerData = DrawerData(tags, sources)
if ((maybeDrawerData != null && maybeDrawerData != apiDrawerData) || maybeDrawerData == null) {
handleDrawerData(apiDrawerData)
}
}
override fun onFailure(call: Call<List<Source>>?, t: Throwable?) {
} else {
val apiDrawerData = DrawerData(tags, null)
if ((maybeDrawerData != null && maybeDrawerData != apiDrawerData) || maybeDrawerData == null) {
handleDrawerData(apiDrawerData)
}
}
})
}
}
}
if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut) && updateSources) {
api.tags.enqueue(object : Callback<List<Tag>> {
override fun onResponse(
call: Call<List<Tag>>,
response: Response<List<Tag>>
) {
tags = response.body()
if (this@HomeActivity.isNetworkAvailable(null, offlineShortcut) && updateSources) {
CoroutineScope(Dispatchers.IO).launch {
val response = api.tags()
if (response != null) {
tags = response
}
sourcesApiCall()
}
override fun onFailure(call: Call<List<Tag>>?, t: Throwable?) {
sourcesApiCall()
}
})
}
}
@ -914,9 +902,10 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun fetchOnEmptyList() {
binding.recyclerView.doOnNextLayout {
if (SharedItems.focusedItems.size - 1 == getLastVisibleItem()) {
getElementsAccordingToTab(true)
}
// Todo:
// if (SharedItems.focusedItems.size - 1 == getLastVisibleItem()) {
// getElementsAccordingToTab(true)
// }
}
}
@ -968,11 +957,12 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
}
offset = if (appendResults) {
SharedItems.focusedItems.size - 1
} else {
0
}
// Todo:
// offset = if (appendResults) {
// SharedItems.focusedItems.size - 1
// } else {
// 0
// }
firstVisible = if (appendResults) firstVisible else 0
doGetAccordingToTab()
@ -980,39 +970,41 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun getUnRead(appendResults: Boolean = false) {
CoroutineScope(Dispatchers.Main).launch {
if (appendResults || !SharedItems.fetchedUnread) {
binding.swipeRefreshLayout.isRefreshing = true
getUnreadItems(applicationContext, api, db, itemsNumber, offset)
binding.swipeRefreshLayout.isRefreshing = false
}
SharedItems.getUnRead()
items = SharedItems.focusedItems
// Todo:
// if (appendResults || !SharedItems.fetchedUnread) {
// binding.swipeRefreshLayout.isRefreshing = true
// service.getUnreadItems(itemsNumber, offset, applicationContext.isNetworkAvailable())
// binding.swipeRefreshLayout.isRefreshing = false
// }
// Todo: SharedItems.getUnRead()
// Todo: items = SharedItems.focusedItems
handleListResult()
}
}
private fun getRead(appendResults: Boolean = false) {
CoroutineScope(Dispatchers.Main).launch {
if (appendResults || !SharedItems.fetchedAll) {
binding.swipeRefreshLayout.isRefreshing = true
getReadItems(applicationContext, api, db, itemsNumber, offset)
binding.swipeRefreshLayout.isRefreshing = false
}
SharedItems.getAll()
items = SharedItems.focusedItems
// Todo:
// if (appendResults || !SharedItems.fetchedAll) {
// binding.swipeRefreshLayout.isRefreshing = true
// service.getReadItems(itemsNumber, offset, applicationContext.isNetworkAvailable())
// binding.swipeRefreshLayout.isRefreshing = false
// }
// SharedItems.getAll()
// items = SharedItems.focusedItems
handleListResult()
}
}
private fun getStarred(appendResults: Boolean = false) {
CoroutineScope(Dispatchers.Main).launch {
if (appendResults || !SharedItems.fetchedStarred) {
if (appendResults || !searchService.fetchedStarred) {
binding.swipeRefreshLayout.isRefreshing = true
getStarredItems(applicationContext, api, db, itemsNumber, offset)
service.getStarredItems(itemsNumber, offset, applicationContext.isNetworkAvailable())
binding.swipeRefreshLayout.isRefreshing = false
}
SharedItems.getStarred()
items = SharedItems.focusedItems
// Todo: SharedItems.getStarred()
// Todo: items = SharedItems.focusedItems
handleListResult()
}
}
@ -1036,6 +1028,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
this,
items,
api,
apiDetailsService,
db,
customTabActivityHelper,
internalBrowser,
@ -1043,7 +1036,8 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
fullHeightCards,
appColors,
userIdentifier,
config
config,
searchService
) {
updateItems(it)
}
@ -1053,13 +1047,15 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
this,
items,
api,
apiDetailsService,
db,
customTabActivityHelper,
internalBrowser,
articleViewer,
userIdentifier,
appColors,
config
config,
searchService
) {
updateItems(it)
}
@ -1083,7 +1079,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun reloadBadges() {
if (displayUnreadCount || displayAllCount) {
CoroutineScope(Dispatchers.Main).launch {
reloadBadges(applicationContext, api)
service.reloadBadges(applicationContext.isNetworkAvailable())
reloadBadgeContent()
}
}
@ -1092,15 +1088,15 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun reloadBadgeContent() {
if (displayUnreadCount) {
tabNewBadge
.setText(SharedItems.badgeUnread.toString())
.setText(searchService.badgeUnread.toString())
.maybeShow()
}
if (displayAllCount) {
tabArchiveBadge
.setText(SharedItems.badgeAll.toString())
.setText(searchService.badgeAll.toString())
.maybeShow()
tabStarredBadge
.setText(SharedItems.badgeStarred.toString())
.setText(searchService.badgeStarred.toString())
.maybeShow()
}
}
@ -1120,7 +1116,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
override fun onQueryTextChange(p0: String?): Boolean {
if (p0.isNullOrBlank()) {
SharedItems.searchFilter = null
searchService.searchFilter = null
getElementsAccordingToTab()
fetchOnEmptyList()
}
@ -1128,7 +1124,7 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
override fun onQueryTextSubmit(p0: String?): Boolean {
SharedItems.searchFilter = p0
searchService.searchFilter = p0
getElementsAccordingToTab()
fetchOnEmptyList()
return false
@ -1158,29 +1154,25 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh -> {
if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) {
if (this@HomeActivity.isNetworkAvailable(null, offlineShortcut)) {
needsConfirmation(R.string.menu_home_refresh, R.string.refresh_dialog_message) {
api.update().enqueue(object : Callback<String> {
override fun onResponse(
call: Call<String>,
response: Response<String>
) {
Toast.makeText(this, R.string.refresh_in_progress, Toast.LENGTH_SHORT).show()
CoroutineScope(Dispatchers.Main).launch {
val status = api.update()
if (status != null && status.isSuccess) {
Toast.makeText(
this@HomeActivity,
R.string.refresh_success_response, Toast.LENGTH_LONG
)
.show()
}
override fun onFailure(call: Call<String>, t: Throwable) {
} else {
Toast.makeText(
this@HomeActivity,
R.string.refresh_failer_message,
Toast.LENGTH_SHORT
).show()
}
})
Toast.makeText(this, R.string.refresh_in_progress, Toast.LENGTH_SHORT).show()
}
}
return true
} else {
@ -1192,9 +1184,9 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
needsConfirmation(R.string.readAll, R.string.markall_dialog_message) {
binding.swipeRefreshLayout.isRefreshing = true
if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) {
if (this@HomeActivity.isNetworkAvailable(null, offlineShortcut)) {
CoroutineScope(Dispatchers.Main).launch {
val success = readAll(applicationContext, api, db)
val success = service.readAll(applicationContext.isNetworkAvailable())
if (success) {
Toast.makeText(
this@HomeActivity,
@ -1230,13 +1222,13 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
private fun maxItemNumber(): Int =
when (elementsShown) {
UNREAD_SHOWN -> SharedItems.badgeUnread
READ_SHOWN -> SharedItems.badgeAll
FAV_SHOWN -> SharedItems.badgeStarred
else -> SharedItems.badgeUnread // if !elementsShown then unread are fetched.
UNREAD_SHOWN -> searchService.badgeUnread
READ_SHOWN -> searchService.badgeAll
FAV_SHOWN -> searchService.badgeStarred
else -> searchService.badgeUnread // if !elementsShown then unread are fetched.
}
private fun updateItems(adapterItems: ArrayList<Item>) {
private fun updateItems(adapterItems: ArrayList<SelfossModel.Item>) {
items = adapterItems
}
@ -1259,32 +1251,24 @@ class HomeActivity : AppCompatActivity(), SearchView.OnQueryTextListener {
}
private fun handleOfflineActions() {
fun <T>doAndReportOnFail(call: Call<T>, action: ActionEntity) {
call.enqueue(object: Callback<T> {
override fun onResponse(
call: Call<T>,
response: Response<T>
) {
fun doAndReportOnFail(call: SelfossModel.SuccessResponse?, action: ActionEntity) {
if (call != null && call.isSuccess) {
thread {
db.actionsDao().delete(action)
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
}
})
}
if (this@HomeActivity.isNetworkAccessible(null, offlineShortcut)) {
if (this@HomeActivity.isNetworkAvailable(null, offlineShortcut)) {
CoroutineScope(Dispatchers.Main).launch {
val actions = db.actionsDao().actions()
actions.forEach { action ->
when {
action.read -> doAndReportOnFail(api.markItem(action.articleId), action)
action.unread -> doAndReportOnFail(api.unmarkItem(action.articleId), action)
action.starred -> doAndReportOnFail(api.starrItem(action.articleId), action)
action.unstarred -> doAndReportOnFail(api.unstarrItem(action.articleId), action)
action.read -> doAndReportOnFail(api.markAsRead(action.articleId), action)
action.unread -> doAndReportOnFail(api.unmarkAsRead(action.articleId), action)
action.starred -> doAndReportOnFail(api.starr(action.articleId), action)
action.unstarred -> doAndReportOnFail(api.unstarr(action.articleId), action)
}
}
}

View File

@ -8,19 +8,26 @@ import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.preference.PreferenceManager
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse
import androidx.work.Logger
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityLoginBinding
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.isBaseUrlValid
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import com.mikepenz.aboutlibraries.LibsBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -120,6 +127,21 @@ class LoginActivity : AppCompatActivity() {
finish()
}
private fun preferenceError(t: Throwable) {
editor.remove("url")
editor.remove("login")
editor.remove("httpUserName")
editor.remove("password")
editor.remove("httpPassword")
editor.apply()
binding.urlView.error = getString(R.string.wrong_infos)
binding.loginView.error = getString(R.string.wrong_infos)
binding.passwordView.error = getString(R.string.wrong_infos)
binding.httpLoginView.error = getString(R.string.wrong_infos)
binding.httpPasswordView.error = getString(R.string.wrong_infos)
showProgress(false)
}
private fun attemptLogin() {
// Reset errors.
@ -198,45 +220,28 @@ class LoginActivity : AppCompatActivity() {
editor.putBoolean("isSelfSignedCert", isWithSelfSignedCert)
editor.apply()
val apiDetailsService = AndroidApiDetailsService(this@LoginActivity)
val api = SelfossApi(
this,
this@LoginActivity,
isWithSelfSignedCert,
-1L
// this,
// this@LoginActivity,
// isWithSelfSignedCert,
// -1L
apiDetailsService
)
if (this@LoginActivity.isNetworkAccessible(this@LoginActivity.findViewById(R.id.loginForm))) {
api.login().enqueue(object : Callback<SuccessResponse> {
private fun preferenceError(t: Throwable) {
editor.remove("url")
editor.remove("login")
editor.remove("httpUserName")
editor.remove("password")
editor.remove("httpPassword")
editor.apply()
binding.urlView.error = getString(R.string.wrong_infos)
binding.loginView.error = getString(R.string.wrong_infos)
binding.passwordView.error = getString(R.string.wrong_infos)
binding.httpLoginView.error = getString(R.string.wrong_infos)
binding.httpPasswordView.error = getString(R.string.wrong_infos)
showProgress(false)
}
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
if (response.body() != null && response.body()!!.isSuccess) {
if (this@LoginActivity.isNetworkAvailable(this@LoginActivity.findViewById(R.id.loginForm))) {
CoroutineScope(Dispatchers.IO).launch {
try {
val result = api.login()
if (result != null && result.isSuccess) {
goToMain()
} else {
preferenceError(Exception("No response body..."))
preferenceError(Exception("Not success"))
}
} catch (cause: Throwable) {
Log.e("1", "LOL")
}
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
preferenceError(t)
}
})
} else {
showProgress(false)
}

View File

@ -5,29 +5,33 @@ import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.view.KeyEvent
import androidx.preference.PreferenceManager
import androidx.appcompat.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceManager
import androidx.room.Room
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.databinding.ActivityReaderBinding
import bou.amine.apps.readerforselfossv2.android.fragments.ArticleFragment
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.themes.Toppings
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.toggleStar
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import com.ftinc.scoop.Scoop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ReaderActivity : AppCompatActivity() {
@ -98,10 +102,11 @@ class ReaderActivity : AppCompatActivity() {
activeAlignment = prefs.getInt("text_align", JUSTIFY)
api = SelfossApi(
this,
this@ReaderActivity,
settings.getBoolean("isSelfSignedCert", false),
prefs.getString("api_timeout", "-1")!!.toLong()
// this,
// this@ReaderActivity,
// settings.getBoolean("isSelfSignedCert", false),
// prefs.getString("api_timeout", "-1")!!.toLong()
AndroidApiDetailsService(this@ReaderActivity)
)
if (allItems.isEmpty()) {
@ -122,9 +127,11 @@ class ReaderActivity : AppCompatActivity() {
binding.indicator.setViewPager(binding.pager)
}
private fun readItem(item: Item) {
private fun readItem(item: SelfossModel.Item) {
if (markOnScroll) {
SharedItems.readItem(applicationContext, api, db, item)
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.readItem(applicationContext, api, db, item)
}
}
}
@ -170,7 +177,7 @@ class ReaderActivity : AppCompatActivity() {
inflater.inflate(R.menu.reader_menu, menu)
toolbarMenu = menu
if (allItems.isNotEmpty() && allItems[currentItem].starred) {
if (allItems.isNotEmpty() && allItems[currentItem].starred == 1) {
canRemoveFromFavorite()
} else {
canFavorite()
@ -187,7 +194,7 @@ class ReaderActivity : AppCompatActivity() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
if (allItems[position].starred) {
if (allItems[position].starred == 1) {
canRemoveFromFavorite()
} else {
canFavorite()
@ -218,21 +225,27 @@ class ReaderActivity : AppCompatActivity() {
return true
}
R.id.star -> {
if (allItems[binding.pager.currentItem].starred) {
SharedItems.unstarItem(
this@ReaderActivity,
api,
db,
allItems[binding.pager.currentItem]
)
if (allItems[binding.pager.currentItem].starred == 1) {
CoroutineScope(Dispatchers.IO).launch {
// Todo:
// SharedItems.unstarItem(
// this@ReaderActivity,
// api,
// db,
// allItems[binding.pager.currentItem]
// )
}
afterUnsave()
} else {
SharedItems.starItem(
this@ReaderActivity,
api,
db,
allItems[binding.pager.currentItem]
)
CoroutineScope(Dispatchers.IO).launch {
// Todo:
// SharedItems.starItem(
// this@ReaderActivity,
// api,
// db,
// allItems[binding.pager.currentItem]
// )
}
afterSave()
}
}
@ -260,6 +273,6 @@ class ReaderActivity : AppCompatActivity() {
}
companion object {
var allItems: ArrayList<Item> = ArrayList()
var allItems: ArrayList<SelfossModel.Item> = ArrayList()
}
}

View File

@ -9,17 +9,23 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import android.widget.Toast
import bou.amine.apps.readerforselfossv2.android.adapters.SourcesListAdapter
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source
import bou.amine.apps.readerforselfossv2.android.databinding.ActivitySourcesBinding
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.themes.Toppings
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import com.ftinc.scoop.Scoop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.ArrayList
class SourcesActivity : AppCompatActivity() {
@ -60,27 +66,27 @@ class SourcesActivity : AppCompatActivity() {
getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE)
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val apiDetailsService = AndroidApiDetailsService(this@SourcesActivity)
val api = SelfossApi(
this,
this@SourcesActivity,
settings.getBoolean("isSelfSignedCert", false),
prefs.getString("api_timeout", "-1")!!.toLong()
// this,
// this@SourcesActivity,
// settings.getBoolean("isSelfSignedCert", false),
// prefs.getString("api_timeout", "-1")!!.toLong()
apiDetailsService
)
var items: ArrayList<Source> = ArrayList()
var items: ArrayList<SelfossModel.Source>
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = mLayoutManager
if (this@SourcesActivity.isNetworkAccessible(binding.recyclerView)) {
api.sources.enqueue(object : Callback<List<Source>> {
override fun onResponse(
call: Call<List<Source>>,
response: Response<List<Source>>
) {
if (response.body() != null && response.body()!!.isNotEmpty()) {
items = response.body() as ArrayList<Source>
}
val mAdapter = SourcesListAdapter(this@SourcesActivity, items, api)
if (this@SourcesActivity.isNetworkAvailable(binding.recyclerView)) {
CoroutineScope(Dispatchers.IO).launch {
val response = api.sources()
if (response != null) {
items = response
val mAdapter = SourcesListAdapter(this@SourcesActivity, items, api,
apiDetailsService
)
binding.recyclerView.adapter = mAdapter
mAdapter.notifyDataSetChanged()
if (items.isEmpty()) {
@ -90,16 +96,14 @@ class SourcesActivity : AppCompatActivity() {
Toast.LENGTH_SHORT
).show()
}
}
override fun onFailure(call: Call<List<Source>>, t: Throwable) {
} else {
Toast.makeText(
this@SourcesActivity,
R.string.cant_get_sources,
Toast.LENGTH_SHORT
).show()
}
})
}
}
binding.fab.setOnClickListener {

View File

@ -2,38 +2,38 @@ package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView.ScaleType
import androidx.recyclerview.widget.RecyclerView
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.databinding.CardItemBinding
import bou.amine.apps.readerforselfossv2.android.model.*
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.buildCustomTabsIntent
import bou.amine.apps.readerforselfossv2.android.utils.*
import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper
import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.android.utils.openInBrowserAsNewTask
import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl
import bou.amine.apps.readerforselfossv2.android.utils.shareLink
import bou.amine.apps.readerforselfossv2.android.utils.sourceAndDateText
import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import com.amulyakhare.textdrawable.TextDrawable
import com.amulyakhare.textdrawable.util.ColorGenerator
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ItemCardAdapter(
override val app: Activity,
override var items: ArrayList<Item>,
override var items: ArrayList<SelfossModel.Item>,
override val api: SelfossApi,
override val apiDetailsService: ApiDetailsService,
override val db: AppDatabase,
private val helper: CustomTabActivityHelper,
private val internalBrowser: Boolean,
@ -42,7 +42,8 @@ class ItemCardAdapter(
override val appColors: AppColors,
override val userIdentifier: String,
override val config: Config,
override val updateItems: (ArrayList<Item>) -> Unit
override val searchService: SearchService,
override val updateItems: (ArrayList<SelfossModel.Item>) -> Unit
) : ItemsAdapter<ItemCardAdapter.ViewHolder>() {
private val c: Context = app.baseContext
private val generator: ColorGenerator = ColorGenerator.MATERIAL
@ -58,30 +59,30 @@ class ItemCardAdapter(
with(holder) {
val itm = items[position]
binding.favButton.isSelected = itm.starred
binding.favButton.isSelected = itm.starred == 1
binding.title.text = itm.getTitleDecoded()
binding.title.setOnTouchListener(LinkOnTouchListener())
binding.title.setLinkTextColor(appColors.colorAccent)
binding.sourceTitleAndDate.text = itm.sourceAndDateText()
binding.sourceTitleAndDate.text = itm.sourceAndDateText(DateUtils(apiDetailsService))
if (!fullHeightCards) {
binding.itemImage.maxHeight = imageMaxHeight
binding.itemImage.scaleType = ScaleType.CENTER_CROP
}
if (itm.getThumbnail(c).isEmpty()) {
if (itm.getThumbnail(apiDetailsService.getBaseUrl()).isEmpty()) {
binding.itemImage.visibility = View.GONE
Glide.with(c).clear(binding.itemImage)
binding.itemImage.setImageDrawable(null)
} else {
binding.itemImage.visibility = View.VISIBLE
c.bitmapCenterCrop(config, itm.getThumbnail(c), binding.itemImage)
c.bitmapCenterCrop(config, itm.getThumbnail(apiDetailsService.getBaseUrl()), binding.itemImage)
}
if (itm.getIcon(c).isEmpty()) {
if (itm.getIcon(apiDetailsService.getBaseUrl()).isEmpty()) {
val color = generator.getColor(itm.getSourceTitle())
val drawable =
@ -91,7 +92,7 @@ class ItemCardAdapter(
.build(itm.getSourceTitle().toTextDrawableString(c), color)
binding.sourceImage.setImageDrawable(drawable)
} else {
c.circularBitmapDrawable(config, itm.getIcon(c), binding.sourceImage)
c.circularBitmapDrawable(config, itm.getIcon(apiDetailsService.getBaseUrl()), binding.sourceImage)
}
}
}
@ -110,14 +111,18 @@ class ItemCardAdapter(
binding.favButton.setOnClickListener {
val item = items[bindingAdapterPosition]
if (isNetworkAvailable(c)) {
if (item.starred) {
SharedItems.unstarItem(c, api, db, item)
item.starred = false
if (c.isNetworkAvailable()) {
if (item.starred == 1) {
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.unstarItem(c, api, db, item)
}
item.starred = 0
binding.favButton.isSelected = false
} else {
SharedItems.starItem(c, api, db, item)
item.starred = true
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.starItem(c, api, db, item)
}
item.starred = 1
binding.favButton.isSelected = true
}
}
@ -145,7 +150,8 @@ class ItemCardAdapter(
customTabsIntent,
internalBrowser,
articleViewer,
app
app,
searchService
)
}
}

View File

@ -2,31 +2,30 @@ package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import androidx.recyclerview.widget.RecyclerView
import bou.amine.apps.readerforselfossv2.android.databinding.ListItemBinding
import bou.amine.apps.readerforselfossv2.android.model.*
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.LinkOnTouchListener
import bou.amine.apps.readerforselfossv2.android.utils.buildCustomTabsIntent
import bou.amine.apps.readerforselfossv2.android.utils.*
import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper
import bou.amine.apps.readerforselfossv2.android.utils.glide.bitmapCenterCrop
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable
import bou.amine.apps.readerforselfossv2.android.utils.openItemUrl
import bou.amine.apps.readerforselfossv2.android.utils.sourceAndDateText
import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import com.amulyakhare.textdrawable.TextDrawable
import com.amulyakhare.textdrawable.util.ColorGenerator
import kotlin.collections.ArrayList
class ItemListAdapter(
override val app: Activity,
override var items: ArrayList<Item>,
override var items: ArrayList<SelfossModel.Item>,
override val api: SelfossApi,
override val apiDetailsService: ApiDetailsService,
override val db: AppDatabase,
private val helper: CustomTabActivityHelper,
private val internalBrowser: Boolean,
@ -34,7 +33,8 @@ class ItemListAdapter(
override val userIdentifier: String,
override val appColors: AppColors,
override val config: Config,
override val updateItems: (ArrayList<Item>) -> Unit
override val searchService: SearchService,
override val updateItems: (ArrayList<SelfossModel.Item>) -> Unit
) : ItemsAdapter<ItemListAdapter.ViewHolder>() {
private val generator: ColorGenerator = ColorGenerator.MATERIAL
private val c: Context = app.baseContext
@ -54,11 +54,11 @@ class ItemListAdapter(
binding.title.setLinkTextColor(appColors.colorAccent)
binding.sourceTitleAndDate.text = itm.sourceAndDateText()
binding.sourceTitleAndDate.text = itm.sourceAndDateText(DateUtils(apiDetailsService))
if (itm.getThumbnail(c).isEmpty()) {
if (itm.getThumbnail(apiDetailsService.getBaseUrl()).isEmpty()) {
if (itm.getIcon(c).isEmpty()) {
if (itm.getIcon(apiDetailsService.getBaseUrl()).isEmpty()) {
val color = generator.getColor(itm.getSourceTitle())
val drawable =
@ -69,10 +69,10 @@ class ItemListAdapter(
binding.itemImage.setImageDrawable(drawable)
} else {
c.circularBitmapDrawable(config, itm.getIcon(c), binding.itemImage)
c.circularBitmapDrawable(config, itm.getIcon(apiDetailsService.getBaseUrl()), binding.itemImage)
}
} else {
c.bitmapCenterCrop(config, itm.getThumbnail(c), binding.itemImage)
c.bitmapCenterCrop(config, itm.getThumbnail(apiDetailsService.getBaseUrl()), binding.itemImage)
}
}
}
@ -97,7 +97,8 @@ class ItemListAdapter(
customTabsIntent,
internalBrowser,
articleViewer,
app
app,
searchService
)
}
}

View File

@ -5,31 +5,37 @@ import android.graphics.Color
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapter<VH>() {
abstract var items: ArrayList<Item>
abstract var items: ArrayList<SelfossModel.Item>
abstract val api: SelfossApi
abstract val apiDetailsService: ApiDetailsService
abstract val db: AppDatabase
abstract val userIdentifier: String
abstract val app: Activity
abstract val appColors: AppColors
abstract val config: Config
abstract val updateItems: (ArrayList<Item>) -> Unit
abstract val searchService: SearchService
abstract val updateItems: (ArrayList<SelfossModel.Item>) -> Unit
fun updateAllItems() {
items = SharedItems.focusedItems
items = ArrayList() // TODO: SharedItems.focusedItems
notifyDataSetChanged()
updateItems(items)
}
private fun unmarkSnackbar(i: Item, position: Int) {
private fun unmarkSnackbar(i: SelfossModel.Item, position: Int) {
val s = Snackbar
.make(
app.findViewById(R.id.coordLayout),
@ -37,12 +43,15 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
Snackbar.LENGTH_LONG
)
.setAction(R.string.undo_string) {
SharedItems.unreadItem(app, api, db, i)
if (SharedItems.displayedItems == "unread") {
addItemAtIndex(i, position)
} else {
notifyItemChanged(position)
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.unreadItem(app, api, db, i)
}
// Todo:
// if (SharedItems.displayedItems == "unread") {
// addItemAtIndex(i, position)
// } else {
// notifyItemChanged(position)
// }
}
val view = s.view
@ -59,14 +68,17 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
Snackbar.LENGTH_LONG
)
.setAction(R.string.undo_string) {
SharedItems.readItem(app, api, db, items[position])
items = SharedItems.focusedItems
if (SharedItems.displayedItems == "unread") {
notifyItemRemoved(position)
updateItems(items)
} else {
notifyItemChanged(position)
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.readItem(app, api, db, items[position])
}
// Todo: items = SharedItems.focusedItems
// Todo:
// if (SharedItems.displayedItems == "unread") {
// notifyItemRemoved(position)
// updateItems(items)
// } else {
// notifyItemChanged(position)
// }
}
val view = s.view
@ -76,40 +88,46 @@ abstract class ItemsAdapter<VH : RecyclerView.ViewHolder?> : RecyclerView.Adapte
}
fun handleItemAtIndex(position: Int) {
if (SharedItems.unreadItemStatusAtIndex(position)) {
readItemAtIndex(position)
} else {
unreadItemAtIndex(position)
}
// Todo:
// if (SharedItems.unreadItemStatusAtIndex(position)) {
// readItemAtIndex(position)
// } else {
// unreadItemAtIndex(position)
// }
}
private fun readItemAtIndex(position: Int) {
val i = items[position]
SharedItems.readItem(app, api, db, i)
if (SharedItems.displayedItems == "unread") {
items.remove(i)
notifyItemRemoved(position)
updateItems(items)
} else {
notifyItemChanged(position)
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.readItem(app, api, db, i)
}
// Todo:
// if (SharedItems.displayedItems == "unread") {
// items.remove(i)
// notifyItemRemoved(position)
// updateItems(items)
// } else {
// notifyItemChanged(position)
// }
unmarkSnackbar(i, position)
}
private fun unreadItemAtIndex(position: Int) {
SharedItems.unreadItem(app, api, db, items[position])
CoroutineScope(Dispatchers.IO).launch {
// Todo: SharedItems.unreadItem(app, api, db, items[position])
}
notifyItemChanged(position)
markSnackbar(position)
}
fun addItemAtIndex(item: Item, position: Int) {
fun addItemAtIndex(item: SelfossModel.Item, position: Int) {
items.add(position, item)
notifyItemInserted(position)
updateItems(items)
}
fun addItemsAtEnd(newItems: List<Item>) {
fun addItemsAtEnd(newItems: List<SelfossModel.Item>) {
val oldSize = items.size
items.addAll(newItems)
notifyItemRangeInserted(oldSize, newItems.size)

View File

@ -2,31 +2,34 @@ package bou.amine.apps.readerforselfossv2.android.adapters
import android.app.Activity
import android.content.Context
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Button
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse
import bou.amine.apps.readerforselfossv2.android.databinding.SourceListItemBinding
import bou.amine.apps.readerforselfossv2.android.model.getIcon
import bou.amine.apps.readerforselfossv2.android.model.getTitleDecoded
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.glide.circularBitmapDrawable
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.android.utils.toTextDrawableString
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import com.amulyakhare.textdrawable.TextDrawable
import com.amulyakhare.textdrawable.util.ColorGenerator
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SourcesListAdapter(
private val app: Activity,
private val items: ArrayList<Source>,
private val api: SelfossApi
private val items: ArrayList<SelfossModel.Source>,
private val api: SelfossApi,
private val apiDetailsService: ApiDetailsService
) : RecyclerView.Adapter<SourcesListAdapter.ViewHolder>() {
private val c: Context = app.baseContext
private val generator: ColorGenerator = ColorGenerator.MATERIAL
@ -42,7 +45,7 @@ class SourcesListAdapter(
val itm = items[position]
config = Config(c)
if (itm.getIcon(c).isEmpty()) {
if (itm.getIcon(apiDetailsService.getBaseUrl()).isEmpty()) {
val color = generator.getColor(itm.getTitleDecoded())
val drawable =
@ -52,7 +55,7 @@ class SourcesListAdapter(
.build(itm.getTitleDecoded().toTextDrawableString(c), color)
binding.itemImage.setImageDrawable(drawable)
} else {
c.circularBitmapDrawable(config, itm.getIcon(c), binding.itemImage)
c.circularBitmapDrawable(config, itm.getIcon(apiDetailsService.getBaseUrl()), binding.itemImage)
}
binding.sourceTitle.text = itm.getTitleDecoded()
@ -71,14 +74,11 @@ class SourcesListAdapter(
val deleteBtn: Button = mView.findViewById(R.id.deleteBtn)
deleteBtn.setOnClickListener {
if (c.isNetworkAccessible(null)) {
if (c.isNetworkAvailable(null)) {
val (id) = items[adapterPosition]
api.deleteSource(id).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
if (response.body() != null && response.body()!!.isSuccess) {
CoroutineScope(Dispatchers.IO).launch {
val action = api.deleteSource(id)
if (action != null && action.isSuccess) {
items.removeAt(adapterPosition)
notifyItemRemoved(adapterPosition)
notifyItemRangeChanged(adapterPosition, itemCount)
@ -90,15 +90,6 @@ class SourcesListAdapter(
).show()
}
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
Toast.makeText(
app,
R.string.can_delete_source,
Toast.LENGTH_SHORT
).show()
}
})
}
}
}

View File

@ -1,22 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
internal class BooleanTypeAdapter : JsonDeserializer<Boolean> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Boolean? =
try {
json.asInt == 1
} catch (e: Exception) {
json.asBoolean
}
}

View File

@ -1,245 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import android.app.Activity
import android.content.Context
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.getUnsafeHttpClient
import com.burgstaller.okhttp.AuthenticationCacheInterceptor
import com.burgstaller.okhttp.CachingAuthenticatorDecorator
import com.burgstaller.okhttp.DispatchingAuthenticator
import com.burgstaller.okhttp.basic.BasicAuthenticator
import com.burgstaller.okhttp.digest.CachingAuthenticator
import com.burgstaller.okhttp.digest.Credentials
import com.burgstaller.okhttp.digest.DigestAuthenticator
import com.google.gson.GsonBuilder
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
class SelfossApi(
c: Context,
callingActivity: Activity?,
isWithSelfSignedCert: Boolean,
timeout: Long
) {
private lateinit var service: SelfossService
private val config: Config = Config(c)
private val userName: String
private val password: String
fun OkHttpClient.Builder.maybeWithSelfSigned(isWithSelfSignedCert: Boolean): OkHttpClient.Builder =
if (isWithSelfSignedCert) {
getUnsafeHttpClient()
} else {
this
}
fun OkHttpClient.Builder.maybeWithSettingsTimeout(timeout: Long): OkHttpClient.Builder =
if (timeout != -1L) {
this.readTimeout(timeout, TimeUnit.SECONDS)
.connectTimeout(timeout, TimeUnit.SECONDS)
} else {
this
}
fun Credentials.createAuthenticator(): DispatchingAuthenticator =
DispatchingAuthenticator.Builder()
.with("digest", DigestAuthenticator(this))
.with("basic", BasicAuthenticator(this))
.build()
fun DispatchingAuthenticator.getHttpClien(isWithSelfSignedCert: Boolean, timeout: Long): OkHttpClient.Builder {
val authCache = ConcurrentHashMap<String, CachingAuthenticator>()
return OkHttpClient
.Builder()
.maybeWithSettingsTimeout(timeout)
.maybeWithSelfSigned(isWithSelfSignedCert)
.authenticator(CachingAuthenticatorDecorator(this, authCache))
.addInterceptor(AuthenticationCacheInterceptor(authCache))
.addInterceptor(object: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
val response: Response = chain.proceed(request)
if (response.code == 408) {
return response
}
return response
}
})
}
init {
userName = config.userLogin
password = config.userPassword
val authenticator =
Credentials(
config.httpUserLogin,
config.httpUserPassword
).createAuthenticator()
val gson =
GsonBuilder()
.registerTypeAdapter(Boolean::class.javaPrimitiveType, BooleanTypeAdapter())
.registerTypeAdapter(SelfossTagType::class.java, SelfossTagTypeTypeAdapter())
.setLenient()
.create()
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.NONE
val httpClient = authenticator.getHttpClien(isWithSelfSignedCert, timeout)
val timeoutCode = 504
httpClient
.addInterceptor { chain ->
val res = chain.proceed(chain.request())
if (res.code == timeoutCode) {
throw SocketTimeoutException("timeout")
}
res
}
.addInterceptor(logging)
.addInterceptor { chain ->
val request = chain.request()
try {
chain.proceed(request)
} catch (e: SocketTimeoutException) {
Response.Builder()
.code(timeoutCode)
.protocol(Protocol.HTTP_2)
.body("".toResponseBody("text/plain".toMediaTypeOrNull()))
.message("")
.request(request)
.build()
}
}
try {
val retrofit =
Retrofit
.Builder()
.baseUrl(config.baseUrl)
.client(httpClient.build())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
service = retrofit.create(SelfossService::class.java)
} catch (e: IllegalArgumentException) {
if (callingActivity != null) {
Config.logoutAndRedirect(c, callingActivity, config.settings.edit(), baseUrlFail = true)
}
}
}
fun login(): Call<SuccessResponse> =
service.loginToSelfoss(config.userLogin, config.userPassword)
suspend fun readItems(
itemsNumber: Int,
offset: Int
): retrofit2.Response<List<Item>> =
getItems("read", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset)
suspend fun newItems(
itemsNumber: Int,
offset: Int
): retrofit2.Response<List<Item>> =
getItems("unread", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset)
suspend fun starredItems(
itemsNumber: Int,
offset: Int
): retrofit2.Response<List<Item>> =
getItems("starred", SharedItems.tagFilter, SharedItems.sourceIDFilter, SharedItems.searchFilter, itemsNumber, offset)
fun allItems(): Call<List<Item>> =
service.allItems(userName, password)
suspend fun allNewItems(): retrofit2.Response<List<Item>> =
getItems("unread", null, null, null, 200, 0)
suspend fun allReadItems(): retrofit2.Response<List<Item>> =
getItems("read", null, null, null, 200, 0)
suspend fun allStarredItems(): retrofit2.Response<List<Item>> =
getItems("read", null, null, null, 200, 0)
private suspend fun getItems(
type: String,
tag: String?,
sourceId: Long?,
search: String?,
items: Int,
offset: Int
): retrofit2.Response<List<Item>> =
service.getItems(type, tag, sourceId, search, null, userName, password, items, offset)
suspend fun updateItems(
updatedSince: String
): retrofit2.Response<List<Item>> =
service.getItems("read", null, null, null, updatedSince, userName, password, 200, 0)
fun markItem(itemId: String): Call<SuccessResponse> =
service.markAsRead(itemId, userName, password)
fun unmarkItem(itemId: String): Call<SuccessResponse> =
service.unmarkAsRead(itemId, userName, password)
suspend fun readAll(ids: List<String>): SuccessResponse =
service.markAllAsRead(ids, userName, password)
fun starrItem(itemId: String): Call<SuccessResponse> =
service.starr(itemId, userName, password)
fun unstarrItem(itemId: String): Call<SuccessResponse> =
service.unstarr(itemId, userName, password)
suspend fun stats(): retrofit2.Response<Stats> = service.stats(userName, password)
val tags: Call<List<Tag>>
get() = service.tags(userName, password)
fun update(): Call<String> =
service.update(userName, password)
val apiVersion: Call<ApiVersion>
get() = service.version()
val sources: Call<List<Source>>
get() = service.sources(userName, password)
fun deleteSource(id: String): Call<SuccessResponse> =
service.deleteSource(id, userName, password)
fun spouts(): Call<Map<String, Spout>> =
service.spouts(userName, password)
fun createSource(
title: String,
url: String,
spout: String,
tags: String,
filter: String
): Call<SuccessResponse> =
service.createSource(title, url, spout, tags, filter, userName, password)
fun createSourceApi2(
title: String,
url: String,
spout: String,
tags: List<String>,
filter: String
): Call<SuccessResponse> =
service.createSourceApi2(title, url, spout, tags, filter, userName, password)
}

View File

@ -1,134 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import android.content.Context
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import kotlinx.coroutines.*
import retrofit2.Response
suspend fun getAndStoreAllItems(context: Context, api: SelfossApi, db: AppDatabase) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
launch {
try {
enqueueArticles(api.allNewItems(), db, true)
} catch (e: Throwable) {}
}
launch {
try {
enqueueArticles(api.allReadItems(), db, false)
} catch (e: Throwable) {}
}
launch {
try {
enqueueArticles(api.allStarredItems(), db, false)
} catch (e: Throwable) {}
}
} else {
launch { SharedItems.updateDatabase(db) }
}
}
suspend fun updateItems(context: Context, api: SelfossApi, db: AppDatabase) = coroutineScope {
if (isNetworkAvailable(context)) {
launch {
try {
enqueueArticles(api.updateItems(SharedItems.items[0].datetime), db, true)
} catch (e: Throwable) {}
}
}
}
suspend fun refreshFocusedItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
val response = when (SharedItems.displayedItems) {
"read" -> api.readItems(itemsNumber, 0)
"unread" -> api.newItems(itemsNumber, 0)
"starred" -> api.starredItems(itemsNumber, 0)
else -> api.readItems(itemsNumber, 0)
}
if (response.isSuccessful) {
SharedItems.refreshFocusedItems(response.body() as ArrayList<Item>)
SharedItems.updateDatabase(db)
}
}
}
suspend fun getReadItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
try {
enqueueArticles(api.readItems( itemsNumber, offset), db, false)
SharedItems.fetchedAll = true
SharedItems.updateDatabase(db)
} catch (e: Throwable) {}
}
}
suspend fun getUnreadItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
try {
if (!SharedItems.fetchedUnread) {
SharedItems.clearDBItems(db)
}
enqueueArticles(api.newItems(itemsNumber, offset), db, false)
SharedItems.fetchedUnread = true
} catch (e: Throwable) {}
}
SharedItems.updateDatabase(db)
}
suspend fun getStarredItems(context: Context, api: SelfossApi, db: AppDatabase, itemsNumber: Int, offset: Int) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
try {
enqueueArticles(api.starredItems(itemsNumber, offset), db, false)
SharedItems.fetchedStarred = true
SharedItems.updateDatabase(db)
} catch (e: Throwable) {
}
}
}
suspend fun readAll(context: Context, api: SelfossApi, db: AppDatabase): Boolean {
var success = false
if (isNetworkAvailable(context)) {
try {
val ids = SharedItems.focusedItems.map { it.id }
if (ids.isNotEmpty()) {
val result = api.readAll(ids)
SharedItems.readItems(db, ids)
success = result.isSuccess
}
} catch (e: Throwable) {}
}
return success
}
suspend fun reloadBadges(context: Context, api: SelfossApi) = withContext(Dispatchers.IO) {
if (isNetworkAvailable(context)) {
try {
val response = api.stats()
if (response.isSuccessful) {
val badges = response.body()
SharedItems.badgeUnread = badges!!.unread
SharedItems.badgeAll = badges.total
SharedItems.badgeStarred = badges.starred
}
} catch (e: Throwable) {}
} else {
SharedItems.computeBadges()
}
}
private fun enqueueArticles(response: Response<List<Item>>, db: AppDatabase, clearDatabase: Boolean) {
if (response.isSuccessful) {
if (clearDatabase) {
CoroutineScope(Dispatchers.IO).launch {
SharedItems.clearDBItems(db)
}
}
val allItems = response.body() as ArrayList<Item>
SharedItems.appendNewItems(allItems)
}
}

View File

@ -1,253 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import android.content.Context
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import android.text.Html
import android.webkit.URLUtil
import org.jsoup.Jsoup
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.isEmptyOrNullOrNullString
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.google.gson.annotations.SerializedName
import java.util.*
import kotlin.collections.ArrayList
private fun constructUrl(config: Config?, path: String, file: String?): String {
return if (file.isEmptyOrNullOrNullString()) {
""
} else {
val baseUriBuilder = Uri.parse(config!!.baseUrl).buildUpon()
baseUriBuilder.appendPath(path).appendPath(file)
baseUriBuilder.toString()
}
}
data class Tag(
@SerializedName("tag") val tag: String,
@SerializedName("color") val color: String,
@SerializedName("unread") val unread: Int
) {
fun getTitleDecoded(): String {
return Html.fromHtml(tag).toString()
}
}
class SuccessResponse(@SerializedName("success") val success: Boolean) {
val isSuccess: Boolean
get() = success
}
class Stats(
@SerializedName("total") val total: Int,
@SerializedName("unread") val unread: Int,
@SerializedName("starred") val starred: Int
)
data class Spout(
@SerializedName("name") val name: String,
@SerializedName("description") val description: String
)
data class ApiVersion(
@SerializedName("version") val version: String?,
@SerializedName("apiversion") val apiversion: String?
) {
fun getApiMajorVersion() : Int {
var versionNumber = 0
if (apiversion != null) {
versionNumber = apiversion.substringBefore(".").toInt()
}
return versionNumber
}
}
data class Source(
@SerializedName("id") val id: String,
@SerializedName("title") val title: String,
@SerializedName("tags") val tags: SelfossTagType,
@SerializedName("spout") val spout: String,
@SerializedName("error") val error: String,
@SerializedName("icon") val icon: String
) {
var config: Config? = null
fun getIcon(app: Context): String {
if (config == null) {
config = Config(app)
}
return constructUrl(config, "favicons", icon)
}
fun getTitleDecoded(): String {
return Html.fromHtml(title).toString()
}
}
data class Item(
@SerializedName("id") val id: String,
@SerializedName("datetime") val datetime: String,
@SerializedName("title") val title: String,
@SerializedName("content") val content: String,
@SerializedName("unread") var unread: Boolean,
@SerializedName("starred") var starred: Boolean,
@SerializedName("thumbnail") val thumbnail: String?,
@SerializedName("icon") val icon: String?,
@SerializedName("link") val link: String,
@SerializedName("sourcetitle") val sourcetitle: String,
@SerializedName("tags") val tags: SelfossTagType
) : Parcelable {
var config: Config? = null
companion object {
@JvmField val CREATOR: Parcelable.Creator<Item> = object : Parcelable.Creator<Item> {
override fun createFromParcel(source: Parcel): Item = Item(source)
override fun newArray(size: Int): Array<Item?> = arrayOfNulls(size)
}
}
constructor(source: Parcel) : this(
id = source.readString().orEmpty(),
datetime = source.readString().orEmpty(),
title = source.readString().orEmpty(),
content = source.readString().orEmpty(),
unread = 0.toByte() != source.readByte(),
starred = 0.toByte() != source.readByte(),
thumbnail = source.readString(),
icon = source.readString(),
link = source.readString().orEmpty(),
sourcetitle = source.readString().orEmpty(),
tags = if (source.readParcelable<SelfossTagType>(ClassLoader.getSystemClassLoader()) != null) source.readParcelable(ClassLoader.getSystemClassLoader())!! else SelfossTagType("")
)
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(id)
dest.writeString(datetime)
dest.writeString(title)
dest.writeString(content)
dest.writeByte((if (unread) 1 else 0))
dest.writeByte((if (starred) 1 else 0))
dest.writeString(thumbnail)
dest.writeString(icon)
dest.writeString(link)
dest.writeString(sourcetitle)
dest.writeParcelable(tags, flags)
}
fun getIcon(app: Context): String {
if (config == null) {
config = Config(app)
}
return constructUrl(config, "favicons", icon)
}
fun getThumbnail(app: Context): String {
if (config == null) {
config = Config(app)
}
return constructUrl(config, "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(Locale.US).contains(".jpg") ||
url.lowercase(Locale.US).contains(".jpeg") ||
url.lowercase(Locale.US).contains(".png") ||
url.lowercase(Locale.US).contains(".webp"))
{
allImages.add(url)
}
}
return allImages
}
fun preloadImages(context: Context) : Boolean {
val imageUrls = this.getImages()
val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL).timeout(10000)
try {
for (url in imageUrls) {
if ( URLUtil.isValidUrl(url)) {
val image = Glide.with(context).asBitmap()
.apply(glideOptions)
.load(url).submit()
}
}
} catch (e : Error) {
return false
}
return true
}
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
}
}
data class SelfossTagType(val tags: String) : Parcelable {
companion object {
@JvmField val CREATOR: Parcelable.Creator<SelfossTagType> =
object : Parcelable.Creator<SelfossTagType> {
override fun createFromParcel(source: Parcel): SelfossTagType =
SelfossTagType(source)
override fun newArray(size: Int): Array<SelfossTagType?> = arrayOfNulls(size)
}
}
constructor(source: Parcel) : this(
tags = source.readString().orEmpty()
)
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(tags)
}
}

View File

@ -1,141 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
internal interface SelfossService {
@GET("login")
fun loginToSelfoss(@Query("username") username: String, @Query("password") password: String): Call<SuccessResponse>
@GET("items")
suspend fun getItems(
@Query("type") type: String,
@Query("tag") tag: String?,
@Query("source") source: Long?,
@Query("search") search: String?,
@Query("updatedsince") updatedSince: String?,
@Query("username") username: String,
@Query("password") password: String,
@Query("items") items: Int,
@Query("offset") offset: Int
): Response<List<Item>>
@GET("items")
fun allItems(
@Query("username") username: String,
@Query("password") password: String
): Call<List<Item>>
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("mark/{id}")
fun markAsRead(
@Path("id") id: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("unmark/{id}")
fun unmarkAsRead(
@Path("id") id: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@FormUrlEncoded
@POST("mark")
suspend fun markAllAsRead(
@Field("ids[]") ids: List<String>,
@Query("username") username: String,
@Query("password") password: String
): SuccessResponse
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("starr/{id}")
fun starr(
@Path("id") id: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("unstarr/{id}")
fun unstarr(
@Path("id") id: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@GET("stats")
suspend fun stats(
@Query("username") username: String,
@Query("password") password: String
): Response<Stats>
@GET("tags")
fun tags(
@Query("username") username: String,
@Query("password") password: String
): Call<List<Tag>>
@GET("update")
fun update(
@Query("username") username: String,
@Query("password") password: String
): Call<String>
@GET("sources/spouts")
fun spouts(
@Query("username") username: String,
@Query("password") password: String
): Call<Map<String, Spout>>
@GET("sources/list")
fun sources(
@Query("username") username: String,
@Query("password") password: String
): Call<List<Source>>
@GET("api/about")
fun version(): Call<ApiVersion>
@DELETE("source/{id}")
fun deleteSource(
@Path("id") id: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@FormUrlEncoded
@POST("source")
fun createSource(
@Field("title") title: String,
@Field("url") url: String,
@Field("spout") spout: String,
@Field("tags") tags: String,
@Field("filter") filter: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
@FormUrlEncoded
@POST("source")
fun createSourceApi2(
@Field("title") title: String,
@Field("url") url: String,
@Field("spout") spout: String,
@Field("tags[]") tags: List<String>,
@Field("filter") filter: String,
@Query("username") username: String,
@Query("password") password: String
): Call<SuccessResponse>
}

View File

@ -1,22 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.api.selfoss
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
internal class SelfossTagTypeTypeAdapter : JsonDeserializer<SelfossTagType> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): SelfossTagType? =
if (json.isJsonArray) {
SelfossTagType(json.asJsonArray.joinToString(",") { it.toString() })
} else {
SelfossTagType(json.toString())
}
}

View File

@ -14,22 +14,27 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import bou.amine.apps.readerforselfossv2.android.MainActivity
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.getAndStoreAllItems
import bou.amine.apps.readerforselfossv2.android.model.preloadImages
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabaseService
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.utils.Config
import bou.amine.apps.readerforselfossv2.android.utils.SharedItems
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.service.SelfossService
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.*
import kotlin.concurrent.schedule
import kotlin.concurrent.thread
@ -43,14 +48,20 @@ override fun doWork(): Result {
val sharedPref = PreferenceManager.getDefaultSharedPreferences(this.context)
val periodicRefresh = sharedPref.getBoolean("periodic_refresh", false)
if (periodicRefresh) {
val apiDetailsService = AndroidApiDetailsService(this.context)
val api = SelfossApi(
this.context,
null,
settings.getBoolean("isSelfSignedCert", false),
sharedPref.getString("api_timeout", "-1")!!.toLong()
// this.context,
// null,
// settings.getBoolean("isSelfSignedCert", false),
// sharedPref.getString("api_timeout", "-1")!!.toLong()
apiDetailsService
)
if (isNetworkAvailable(context)) {
val dateUtils = DateUtils(apiDetailsService)
val searchService = SearchService(dateUtils)
val service = SelfossService(api, AndroidDeviceDatabaseService(AndroidDeviceDatabase(applicationContext), searchService), searchService)
if (context.isNetworkAvailable()) {
CoroutineScope(Dispatchers.IO).launch {
val notificationManager =
@ -80,26 +91,26 @@ override fun doWork(): Result {
actions.forEach { action ->
when {
action.read -> doAndReportOnFail(
api.markItem(action.articleId),
api.markAsRead(action.articleId),
action
)
action.unread -> doAndReportOnFail(
api.unmarkItem(action.articleId),
api.unmarkAsRead(action.articleId),
action
)
action.starred -> doAndReportOnFail(
api.starrItem(action.articleId),
api.starr(action.articleId),
action
)
action.unstarred -> doAndReportOnFail(
api.unstarrItem(action.articleId),
api.unstarr(action.articleId),
action
)
}
}
getAndStoreAllItems(context, api, db)
SharedItems.updateDatabase(db)
service.getAndStoreAllItems(context.isNetworkAvailable())
// TODO: SharedItems.updateDatabase(db, dateUtils)
storeItems(notifyNewItems, notificationManager)
}
}
@ -109,10 +120,10 @@ override fun doWork(): Result {
private fun storeItems(notifyNewItems: Boolean, notificationManager: NotificationManager) {
CoroutineScope(Dispatchers.IO).launch {
val apiItems = SharedItems.items
val apiItems = emptyList<SelfossModel.Item>() // TODO: SharedItems.items
val newSize = apiItems.filter { it.unread }.size
val newSize = apiItems.filter { it.unread == 1 }.size
if (notifyNewItems && newSize > 0) {
val intent = Intent(context, MainActivity::class.java).apply {
@ -151,19 +162,11 @@ override fun doWork(): Result {
}
}
private fun <T> doAndReportOnFail(call: Call<T>, action: ActionEntity) {
call.enqueue(object : Callback<T> {
override fun onResponse(
call: Call<T>,
response: Response<T>
) {
private fun doAndReportOnFail(result: SelfossModel.SuccessResponse?, action: ActionEntity) {
if (result != null && result.isSuccess) {
thread {
db.actionsDao().delete(action)
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
}
})
}
}

View File

@ -10,39 +10,51 @@ import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Bundle
import androidx.preference.PreferenceManager
import android.view.*
import android.webkit.*
import android.widget.Toast
import com.google.android.material.floatingactionbutton.FloatingActionButton
import androidx.fragment.app.Fragment
import androidx.core.widget.NestedScrollView
import androidx.appcompat.app.AlertDialog
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.res.ResourcesCompat
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import androidx.room.Room
import bou.amine.apps.readerforselfossv2.android.ImageActivity
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.mercury.MercuryApi
import bou.amine.apps.readerforselfossv2.android.api.mercury.ParsedContent
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.databinding.FragmentArticleBinding
import bou.amine.apps.readerforselfossv2.android.model.*
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.AndroidDeviceDatabaseService
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4
import bou.amine.apps.readerforselfossv2.android.service.AndroidApiDetailsService
import bou.amine.apps.readerforselfossv2.android.themes.AppColors
import bou.amine.apps.readerforselfossv2.android.utils.*
import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper
import bou.amine.apps.readerforselfossv2.android.utils.glide.loadMaybeBasicAuth
import bou.amine.apps.readerforselfossv2.android.utils.glide.getBitmapInputStream
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.*
import bou.amine.apps.readerforselfossv2.android.utils.glide.loadMaybeBasicAuth
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAvailable
import bou.amine.apps.readerforselfossv2.rest.SelfossApi
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.service.SelfossService
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import bou.amine.apps.readerforselfossv2.utils.isEmptyOrNullOrNullString
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.github.rubensousa.floatingtoolbar.FloatingToolbar
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
@ -50,11 +62,13 @@ import java.net.MalformedURLException
import java.net.URL
import java.util.*
import java.util.concurrent.ExecutionException
import kotlin.collections.ArrayList
class ArticleFragment : Fragment() {
private lateinit var dbService: AndroidDeviceDatabaseService
private lateinit var apiDetailsService: ApiDetailsService
private lateinit var service: SelfossService<AndroidItemEntity>
private var fontSize: Int = 16
private lateinit var item: Item
private lateinit var item: SelfossModel.Item
private var mCustomTabActivityHelper: CustomTabActivityHelper? = null
private lateinit var url: String
private lateinit var contentText: String
@ -91,6 +105,12 @@ class ArticleFragment : Fragment() {
super.onCreate(savedInstanceState)
apiDetailsService = AndroidApiDetailsService(requireContext())
dbService = AndroidDeviceDatabaseService(AndroidDeviceDatabase(requireContext()), SearchService(DateUtils(apiDetailsService)))
service = SelfossService(SelfossApi(apiDetailsService), dbService, SearchService(DateUtils(apiDetailsService)))
item = requireArguments().getParcelable(ARG_ITEMS)!!
db = Room.databaseBuilder(
@ -110,8 +130,8 @@ class ArticleFragment : Fragment() {
url = item.getLinkDecoded()
contentText = item.content
contentTitle = item.getTitleDecoded()
contentImage = item.getThumbnail(requireActivity())
contentSource = item.sourceAndDateText()
contentImage = item.getThumbnail(apiDetailsService.getBaseUrl())
contentSource = item.sourceAndDateText(DateUtils(apiDetailsService))
allImages = item.getImages()
prefs = PreferenceManager.getDefaultSharedPreferences(activity)
@ -136,10 +156,11 @@ class ArticleFragment : Fragment() {
val settings = requireActivity().getSharedPreferences(Config.settingsName, Context.MODE_PRIVATE)
val api = SelfossApi(
requireContext(),
requireActivity(),
settings.getBoolean("isSelfSignedCert", false),
prefs.getString("api_timeout", "-1")!!.toLong()
// requireContext(),
// requireActivity(),
// settings.getBoolean("isSelfSignedCert", false),
// prefs.getString("api_timeout", "-1")!!.toLong()
apiDetailsService
)
fab = binding.fab
@ -166,27 +187,33 @@ class ArticleFragment : Fragment() {
R.id.share_action -> requireActivity().shareLink(url, contentTitle)
R.id.open_action -> requireActivity().openInBrowserAsNewTask(this@ArticleFragment.item)
R.id.unread_action -> if (context != null) {
if (this@ArticleFragment.item.unread) {
SharedItems.readItem(
context!!,
api,
db,
this@ArticleFragment.item
)
this@ArticleFragment.item.unread = false
if (this@ArticleFragment.item.unread == 1) {
CoroutineScope(Dispatchers.IO).launch {
// TODO:
// dbService.readItem(
// context!!,
// api,
// db,
// this@ArticleFragment.item
// )
}
this@ArticleFragment.item.unread = 0
Toast.makeText(
context,
R.string.marked_as_read,
Toast.LENGTH_LONG
).show()
} else {
SharedItems.unreadItem(
context!!,
api,
db,
this@ArticleFragment.item
)
this@ArticleFragment.item.unread = true
CoroutineScope(Dispatchers.IO).launch {
// TODO
// .unreadItem(
// context!!,
// api,
// db,
// this@ArticleFragment.item
// )
}
this@ArticleFragment.item.unread = 1
Toast.makeText(
context,
R.string.marked_as_unread,
@ -284,7 +311,7 @@ class ArticleFragment : Fragment() {
}
private fun getContentFromMercury(customTabsIntent: CustomTabsIntent) {
if ((context != null && requireContext().isNetworkAccessible(null)) || context == null) {
if ((context != null && requireContext().isNetworkAvailable(null)) || context == null) {
binding.progressBar.visibility = View.VISIBLE
val parser = MercuryApi()
@ -535,11 +562,11 @@ class ArticleFragment : Fragment() {
private const val ARG_ITEMS = "items"
fun newInstance(
item: Item
item: SelfossModel.Item
): ArticleFragment {
val fragment = ArticleFragment()
val args = Bundle()
args.putParcelable(ARG_ITEMS, item)
args.putParcelable(ARG_ITEMS, item.toParcelable())
fragment.arguments = args
return fragment
}

View File

@ -0,0 +1,125 @@
package bou.amine.apps.readerforselfossv2.android.model
import android.content.Context
import android.net.Uri
import android.text.Html
import android.webkit.URLUtil
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import org.jsoup.Jsoup
import java.util.*
/**
* Items extension methods
*/
fun SelfossModel.Item.getIcon(baseUrl: String): String {
return constructUrl(baseUrl, "favicons", icon)
}
fun SelfossModel.Item.getThumbnail(baseUrl: String): String {
return constructUrl(baseUrl, "thumbnails", thumbnail)
}
fun SelfossModel.Item.getImages() : ArrayList<String> {
val allImages = ArrayList<String>()
for ( image in Jsoup.parse(content).getElementsByTag("img")) {
val url = image.attr("src")
if (url.lowercase(Locale.US).contains(".jpg") ||
url.lowercase(Locale.US).contains(".jpeg") ||
url.lowercase(Locale.US).contains(".png") ||
url.lowercase(Locale.US).contains(".webp"))
{
allImages.add(url)
}
}
return allImages
}
fun SelfossModel.Item.preloadImages(context: Context) : Boolean {
val imageUrls = this.getImages()
val glideOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.ALL).timeout(10000)
try {
for (url in imageUrls) {
if ( URLUtil.isValidUrl(url)) {
Glide.with(context).asBitmap()
.apply(glideOptions)
.load(url).submit()
}
}
} catch (e : Error) {
return false
}
return true
}
fun SelfossModel.Item.getTitleDecoded(): String {
return Html.fromHtml(title).toString()
}
fun SelfossModel.Item.getSourceTitle(): String {
return Html.fromHtml(sourcetitle).toString()
}
// TODO: maybe find a better way to handle these kind of urls
fun SelfossModel.Item.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
}
/**
* Sources extension methods
*/
fun SelfossModel.Source.getIcon(baseUrl: String): String {
return constructUrl(baseUrl, "favicons", icon)
}
fun SelfossModel.Source.getTitleDecoded(): String {
return Html.fromHtml(title).toString()
}
/**
* Common methods
*/
private fun constructUrl(baseUrl: String, path: String, file: String?): String {
return if (file == null || file == "null" || file.isEmpty()) {
""
} else {
val baseUriBuilder = Uri.parse(baseUrl).buildUpon()
baseUriBuilder.appendPath(path).appendPath(file)
baseUriBuilder.toString()
}
}

View File

@ -0,0 +1,73 @@
package bou.amine.apps.readerforselfossv2.android.model
import android.os.Parcel
import android.os.Parcelable
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import com.google.gson.annotations.SerializedName
fun SelfossModel.Item.toParcelable() : ParecelableItem =
ParecelableItem(
this.id,
this.datetime,
this.title,
this.content,
this.unread,
this.starred,
this.thumbnail,
this.icon,
this.link,
this.sourcetitle,
this.tags
)
data class ParecelableItem(
@SerializedName("id") val id: String,
@SerializedName("datetime") val datetime: String,
@SerializedName("title") val title: String,
@SerializedName("content") val content: String,
@SerializedName("unread") var unread: Int,
@SerializedName("starred") var starred: Int,
@SerializedName("thumbnail") val thumbnail: String?,
@SerializedName("icon") val icon: String?,
@SerializedName("link") val link: String,
@SerializedName("sourcetitle") val sourcetitle: String,
@SerializedName("tags") val tags: String
) : Parcelable {
companion object {
@JvmField
val CREATOR: Parcelable.Creator<ParecelableItem> = object : Parcelable.Creator<ParecelableItem> {
override fun createFromParcel(source: Parcel): ParecelableItem = ParecelableItem(source)
override fun newArray(size: Int): Array<ParecelableItem?> = arrayOfNulls(size)
}
}
constructor(source: Parcel) : this(
id = source.readString().orEmpty(),
datetime = source.readString().orEmpty(),
title = source.readString().orEmpty(),
content = source.readString().orEmpty(),
unread = source.readInt(),
starred = source.readInt(),
thumbnail = source.readString(),
icon = source.readString(),
link = source.readString().orEmpty(),
sourcetitle = source.readString().orEmpty(),
tags = source.readString().orEmpty()
)
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(id)
dest.writeString(datetime)
dest.writeString(title)
dest.writeString(content)
dest.writeInt(unread)
dest.writeInt(starred)
dest.writeString(thumbnail)
dest.writeString(icon)
dest.writeString(link)
dest.writeString(sourcetitle)
dest.writeString(tags)
}
}

View File

@ -0,0 +1,28 @@
package bou.amine.apps.readerforselfossv2.android.persistence
import android.content.Context
import androidx.room.Room
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_1_2
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_2_3
import bou.amine.apps.readerforselfossv2.android.persistence.migrations.MIGRATION_3_4
import bou.amine.apps.readerforselfossv2.dao.DeviceDatabase
class AndroidDeviceDatabase(applicationContext: Context): DeviceDatabase<AndroidItemEntity> {
var db: AppDatabase = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java, "selfoss-database"
).addMigrations(MIGRATION_1_2).addMigrations(MIGRATION_2_3).addMigrations(MIGRATION_3_4).build()
override suspend fun items(): List<AndroidItemEntity> = db.itemsDao().items()
override suspend fun insertAllItems(vararg items: AndroidItemEntity) = db.itemsDao().insertAllItems(*items)
override suspend fun deleteAllItems() = db.itemsDao().deleteAllItems()
override suspend fun delete(item: AndroidItemEntity) = db.itemsDao().delete(item)
override suspend fun updateItem(item: AndroidItemEntity) = db.itemsDao().updateItem(item)
}

View File

@ -0,0 +1,40 @@
package bou.amine.apps.readerforselfossv2.android.persistence
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.utils.persistence.toEntity
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.DeviceDataBaseService
import bou.amine.apps.readerforselfossv2.service.SearchService
class AndroidDeviceDatabaseService(db: AndroidDeviceDatabase, searchService: SearchService) :
DeviceDataBaseService<AndroidItemEntity>(db, searchService) {
override suspend fun updateDatabase() {
if (itemsCaching) {
if (items.isEmpty()) {
getFromDB()
}
db.deleteAllItems()
db.insertAllItems(*(items.map { it.toEntity() }).toTypedArray())
}
}
override suspend fun clearDBItems() {
db.deleteAllItems()
}
override fun appendNewItems(newItems: List<SelfossModel.Item>) {
var tmpItems = items
if (tmpItems != newItems) {
tmpItems = tmpItems.filter { item -> newItems.find { it.id == item.id } == null } as ArrayList<SelfossModel.Item>
tmpItems.addAll(newItems)
items = tmpItems
sortItems()
getFocusedItems()
}
}
override fun getFromDB() {
TODO("Not yet implemented")
}
}

View File

@ -5,7 +5,7 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import androidx.room.Update
@ -13,17 +13,17 @@ import androidx.room.Update
@Dao
interface ItemsDao {
@Query("SELECT * FROM items order by id desc")
suspend fun items(): List<ItemEntity>
suspend fun items(): List<AndroidItemEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllItems(vararg items: ItemEntity)
suspend fun insertAllItems(vararg items: AndroidItemEntity)
@Query("DELETE FROM items")
suspend fun deleteAllItems()
@Delete
suspend fun delete(item: ItemEntity)
suspend fun delete(item: AndroidItemEntity)
@Update
suspend fun updateItem(item: ItemEntity)
suspend fun updateItem(item: AndroidItemEntity)
}

View File

@ -6,11 +6,11 @@ import bou.amine.apps.readerforselfossv2.android.persistence.dao.ActionsDao
import bou.amine.apps.readerforselfossv2.android.persistence.dao.DrawerDataDao
import bou.amine.apps.readerforselfossv2.android.persistence.dao.ItemsDao
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.SourceEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.TagEntity
@Database(entities = [TagEntity::class, SourceEntity::class, ItemEntity::class, ActionEntity::class], version = 4)
@Database(entities = [TagEntity::class, SourceEntity::class, AndroidItemEntity::class, ActionEntity::class], version = 4)
abstract class AppDatabase : RoomDatabase() {
abstract fun drawerDataDao(): DrawerDataDao

View File

@ -3,9 +3,10 @@ package bou.amine.apps.readerforselfossv2.android.persistence.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
@Entity(tableName = "items")
data class ItemEntity(
data class AndroidItemEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,

View File

@ -0,0 +1,47 @@
package bou.amine.apps.readerforselfossv2.android.service
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.preference.PreferenceManager
import bou.amine.apps.readerforselfossv2.service.ApiDetailsService
class AndroidApiDetailsService(c: Context) : ApiDetailsService {
val settings: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(c)
private var apiVersion: Int = -1
private var baseUrl: String = ""
private var userName: String = ""
private var password: String = ""
override fun logApiCalls(message: String) {
Log.d("LogApiCalls", message)
}
override fun getApiVersion(): Int {
if (apiVersion != -1) {
apiVersion = settings.getInt("apiVersion", -1)!!
}
return apiVersion
}
override fun getBaseUrl(): String {
if (baseUrl.isEmpty()) {
baseUrl = settings.getString("url", "")!!
}
return baseUrl
}
override fun getUserName(): String {
if (userName.isEmpty()) {
userName = settings.getString("login", "")!!
}
return userName
}
override fun getPassword(): String {
if (password.isEmpty()) {
password = settings.getString("password", "")!!
}
return password
}
}

View File

@ -1,7 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.utils
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse
import retrofit2.Response
fun Response<SuccessResponse>.succeeded(): Boolean =
this.code() === 200 && this.body() != null && this.body()!!.isSuccess

View File

@ -3,27 +3,7 @@ package bou.amine.apps.readerforselfossv2.android.utils
import android.content.Context
import android.content.Intent
import bou.amine.apps.readerforselfossv2.android.R
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
}
import bou.amine.apps.readerforselfossv2.utils.toStringUriWithHttp
fun Context.shareLink(itemUrl: String, itemTitle: String) {
val sendIntent = Intent()

View File

@ -1,31 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.utils
import android.text.format.DateUtils
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
fun parseDate(dateString: String): Instant {
val FORMATTERV1 = "yyyy-MM-dd HH:mm:ss"
return if (Config.apiVersion >= 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(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
}

View File

@ -1,8 +1,10 @@
package bou.amine.apps.readerforselfossv2.android.utils
import android.content.Context
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossTagType
import bou.amine.apps.readerforselfossv2.android.model.getSourceTitle
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.utils.DateUtils
import bou.amine.apps.readerforselfossv2.utils.parseRelativeDate
fun String.toTextDrawableString(c: Context): String {
val textDrawable = StringBuilder()
@ -15,22 +17,22 @@ fun String.toTextDrawableString(c: Context): String {
return textDrawable.toString()
}
fun Item.sourceAndDateText(): String {
val formattedDate = parseRelativeDate(this.datetime)
fun SelfossModel.Item.sourceAndDateText(dateUtils: DateUtils): String {
val formattedDate = parseRelativeDate(dateUtils)
return this.getSourceTitle() + formattedDate
return getSourceTitle() + formattedDate
}
fun Item.toggleStar(): Item {
this.starred = !this.starred
fun SelfossModel.Item.toggleStar(): SelfossModel.Item {
this.starred = if (this.starred == 0) 1 else 0
return this
}
fun List<Item>.flattenTags(): List<Item> =
fun List<SelfossModel.Item>.flattenTags(): List<SelfossModel.Item> =
this.flatMap {
val item = it
val tags: List<String> = it.tags.tags.split(",")
val tags: List<String> = it.tags.split(",")
tags.map { t ->
item.copy(tags = SelfossTagType(t.trim()))
item.copy(tags = t.trim())
}
}

View File

@ -18,8 +18,11 @@ import android.widget.TextView
import android.widget.Toast
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.ReaderActivity
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.model.getLinkDecoded
import bou.amine.apps.readerforselfossv2.android.utils.customtabs.CustomTabActivityHelper
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
import bou.amine.apps.readerforselfossv2.service.SearchService
import bou.amine.apps.readerforselfossv2.utils.toStringUriWithHttp
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
fun Context.buildCustomTabsIntent(): CustomTabsIntent {
@ -71,16 +74,17 @@ fun Context.buildCustomTabsIntent(): CustomTabsIntent {
}
fun Context.openItemUrlInternally(
allItems: ArrayList<Item>,
allItems: ArrayList<SelfossModel.Item>,
currentItem: Int,
linkDecoded: String,
customTabsIntent: CustomTabsIntent,
articleViewer: Boolean,
app: Activity
app: Activity,
searchService: SearchService
) {
if (articleViewer) {
ReaderActivity.allItems = allItems
SharedItems.position = currentItem
searchService.position = currentItem
val intent = Intent(this, ReaderActivity::class.java)
intent.putExtra("currentItem", currentItem)
app.startActivity(intent)
@ -113,13 +117,14 @@ fun Context.openItemUrlInternalBrowser(
}
fun Context.openItemUrl(
allItems: ArrayList<Item>,
allItems: ArrayList<SelfossModel.Item>,
currentItem: Int,
linkDecoded: String,
customTabsIntent: CustomTabsIntent,
internalBrowser: Boolean,
articleViewer: Boolean,
app: Activity
app: Activity,
searchService: SearchService
) {
if (!linkDecoded.isUrlValid()) {
@ -138,7 +143,8 @@ fun Context.openItemUrl(
linkDecoded,
customTabsIntent,
articleViewer,
app
app,
searchService
)
} else {
this.openItemUrlInternalBrowser(
@ -174,7 +180,7 @@ fun String.isBaseUrlValid(ctx: Context): Boolean {
return Patterns.WEB_URL.matcher(this).matches() && existsAndEndsWithSlash
}
fun Context.openInBrowserAsNewTask(i: Item) {
fun Context.openInBrowserAsNewTask(i: SelfossModel.Item) {
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.data = Uri.parse(i.getLinkDecoded().toStringUriWithHttp())

View File

@ -1,403 +0,0 @@
package bou.amine.apps.readerforselfossv2.android.utils
import android.content.Context
import android.widget.Toast
import bou.amine.apps.readerforselfossv2.android.R
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossApi
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SuccessResponse
import bou.amine.apps.readerforselfossv2.android.persistence.database.AppDatabase
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ActionEntity
import bou.amine.apps.readerforselfossv2.android.utils.persistence.toEntity
import bou.amine.apps.readerforselfossv2.android.utils.network.isNetworkAccessible
import bou.amine.apps.readerforselfossv2.android.utils.persistence.toView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlin.concurrent.thread
/*
* Singleton class that contains the articles fetched from Selfoss, it allows sharing the items list
* between Activities and Fragments
*/
object SharedItems {
var items: ArrayList<Item> = arrayListOf<Item>()
get() {
return ArrayList(field)
}
set(value) {
field = ArrayList(value)
}
var focusedItems: ArrayList<Item> = arrayListOf<Item>()
get() {
return ArrayList(field)
}
set(value) {
field = ArrayList(value)
}
var position = 0
set(value) {
field = when {
value < 0 -> 0
value > items.size -> items.size
else -> value
}
}
var displayedItems: String = "unread"
set(value) {
field = when (value) {
"all" -> "all"
"unread" -> "unread"
"read" -> "read"
"starred" -> "starred"
else -> "all"
}
}
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
/**
* Add new items to the SharedItems list
*
* The new items are considered more updated than the ones already in the list.
* The old items present in the new list are discarded and replaced by the new ones.
* Items are compared according to the selfoss id, which should always be unique.
*/
fun appendNewItems(newItems: ArrayList<Item>) {
var tmpItems = items
if (tmpItems != newItems) {
tmpItems = tmpItems.filter { item -> newItems.find { it.id == item.id } == null } as ArrayList<Item>
tmpItems.addAll(newItems)
items = tmpItems
sortItems()
getFocusedItems()
}
}
fun refreshFocusedItems(newItems: ArrayList<Item>) {
val tmpItems = items
tmpItems.removeAll(focusedItems)
appendNewItems(newItems)
}
suspend fun clearDBItems(db: AppDatabase) {
db.itemsDao().deleteAllItems()
}
suspend fun updateDatabase(db: AppDatabase) {
if (itemsCaching) {
if (items.isEmpty()) {
getFromDB(db)
}
db.itemsDao().deleteAllItems()
db.itemsDao().insertAllItems(*(items.map { it.toEntity() }).toTypedArray())
}
}
fun filter() {
fun filterSearch(item: Item): Boolean {
return if (!searchFilter.isEmptyOrNullOrNullString()) {
var matched = item.title.contains(searchFilter.toString(), true)
matched = matched || item.content.contains(searchFilter.toString(), true)
matched = matched || item.sourcetitle.contains(searchFilter.toString(), true)
matched
} else {
true
}
}
var tmpItems = focusedItems
if (tagFilter != null) {
tmpItems = tmpItems.filter { it.tags.tags.contains(tagFilter.toString()) } as ArrayList<Item>
}
if (searchFilter != null) {
tmpItems = tmpItems.filter { filterSearch(it) } as ArrayList<Item>
}
if (sourceFilter != null) {
tmpItems = tmpItems.filter { it.sourcetitle == sourceFilter } as ArrayList<Item>
}
focusedItems = tmpItems
}
private fun getFocusedItems() {
when (displayedItems) {
"all" -> getAll()
"unread" -> getUnRead()
"read" -> getRead()
"starred" -> getStarred()
else -> getUnRead()
}
}
fun getUnRead() {
displayedItems = "unread"
focusedItems = items.filter { item -> item.unread } as ArrayList<Item>
filter()
}
fun getRead() {
displayedItems = "read"
focusedItems = items.filter { item -> !item.unread } as ArrayList<Item>
filter()
}
fun getStarred() {
displayedItems = "starred"
focusedItems = items.filter { item -> item.starred } as ArrayList<Item>
filter()
}
fun getAll() {
displayedItems = "all"
focusedItems = items
filter()
}
suspend fun getFromDB(db: AppDatabase) {
if (itemsCaching) {
val dbItems = db.itemsDao().items().map { it.toView() } as ArrayList<Item>
appendNewItems(dbItems)
}
}
private fun removeItemAtIndex(index: Int) {
val i = focusedItems[index]
val tmpItems = focusedItems
tmpItems.remove(i)
focusedItems = tmpItems
}
fun addItemAtIndex(newItem: Item, index: Int) {
val tmpItems = focusedItems
tmpItems.add(index, newItem)
focusedItems = tmpItems
}
fun readItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) {
if (items.contains(item)) {
position = items.indexOf(item)
readItemAtPosition(app, api, db)
}
}
fun readItems(db: AppDatabase, ids: List<String>) {
for (id in ids) {
val match = items.filter { it -> it.id == id }
if (match.isNotEmpty() && match.size == 1) {
position = items.indexOf(match[0])
val tmpItems = items
tmpItems[position].unread = false
items = tmpItems
resetDBItem(db)
badgeUnread--
}
}
}
private fun readItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) {
val i = items[position]
if (app.isNetworkAccessible(null)) {
api.markItem(i.id).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
val tmpItems = items
tmpItems[position].unread = false
items = tmpItems
resetDBItem(db)
getFocusedItems()
badgeUnread--
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
Toast.makeText(
app,
app.getString(R.string.cant_mark_read),
Toast.LENGTH_SHORT
).show()
}
})
} else if (itemsCaching) {
thread {
db.actionsDao().insertAllActions(ActionEntity(i.id, true, false, false, false))
}
}
if (position > items.size) {
position -= 1
}
}
fun unreadItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) {
if (items.contains(item) && !item.unread) {
position = items.indexOf(item)
unreadItemAtPosition(app, api, db)
}
}
private fun unreadItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) {
val i = items[position]
if (app.isNetworkAccessible(null)) {
api.unmarkItem(i.id).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
val tmpItems = items
tmpItems[position].unread = true
items = tmpItems
resetDBItem(db)
getFocusedItems()
badgeUnread++
}
override fun onFailure(call: Call<SuccessResponse>, t: Throwable) {
Toast.makeText(
app,
app.getString(R.string.cant_mark_unread),
Toast.LENGTH_SHORT
).show()
}
})
} else if (itemsCaching) {
thread {
db.actionsDao().insertAllActions(ActionEntity(i.id, false, true, false, false))
}
}
}
fun starItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) {
if (items.contains(item) && !item.starred) {
position = items.indexOf(item)
starItemAtPosition(app, api, db)
}
}
private fun starItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) {
val i = items[position]
if (app.isNetworkAccessible(null)) {
api.starrItem(i.id).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
val tmpItems = items
tmpItems[position].starred = true
items = tmpItems
resetDBItem(db)
getFocusedItems()
badgeStarred++
}
override fun onFailure(
call: Call<SuccessResponse>,
t: Throwable
) {
Toast.makeText(
app,
app.getString(R.string.cant_mark_favortie),
Toast.LENGTH_SHORT
).show()
}
})
} else {
thread {
db.actionsDao().insertAllActions(ActionEntity(i.id, false, false, true, false))
}
}
}
fun unstarItem(app: Context, api: SelfossApi, db: AppDatabase, item: Item) {
if (items.contains(item) && item.starred) {
position = items.indexOf(item)
unstarItemAtPosition(app, api, db)
}
}
private fun unstarItemAtPosition(app: Context, api: SelfossApi, db: AppDatabase) {
val i = items[position]
if (app.isNetworkAccessible(null)) {
api.unstarrItem(i.id).enqueue(object : Callback<SuccessResponse> {
override fun onResponse(
call: Call<SuccessResponse>,
response: Response<SuccessResponse>
) {
val tmpItems = items
tmpItems[position].starred = false
items = tmpItems
resetDBItem(db)
getFocusedItems()
badgeStarred--
}
override fun onFailure(
call: Call<SuccessResponse>,
t: Throwable
) {
Toast.makeText(
app,
app.getString(R.string.cant_unmark_favortie),
Toast.LENGTH_SHORT
).show()
}
})
} else {
thread {
db.actionsDao().insertAllActions(ActionEntity(i.id, false, false, false, true))
}
}
}
private fun resetDBItem(db: AppDatabase) {
if (itemsCaching) {
val i = items[position]
CoroutineScope(Dispatchers.IO).launch {
db.itemsDao().delete(i.toEntity())
db.itemsDao().insertAllItems(i.toEntity())
}
}
}
fun unreadItemStatusAtIndex(position: Int): Boolean {
return focusedItems[position].unread
}
fun computeBadges() {
badgeUnread = items.filter { item -> item.unread }.size
badgeStarred = items.filter { item -> item.starred }.size
badgeAll = items.size
}
private fun sortItems() {
val tmpItems = ArrayList(items.sortedByDescending { parseDate(it.datetime) })
items = tmpItems
}
}

View File

@ -14,8 +14,11 @@ var snackBarShown = false
var view: View? = null
lateinit var s: Snackbar
fun Context.isNetworkAccessible(v: View?, overrideOffline: Boolean = false): Boolean {
val networkIsAccessible = isNetworkAvailable(this)
fun Context.isNetworkAvailable(
v: View? = null,
overrideOffline: Boolean = false
): Boolean {
val networkIsAccessible = isNetworkAccessible(this)
if (v != null && (!networkIsAccessible || overrideOffline) && (!snackBarShown || v != view)) {
view = v
@ -43,7 +46,7 @@ fun Context.isNetworkAccessible(v: View?, overrideOffline: Boolean = false): Boo
return if(overrideOffline) overrideOffline else networkIsAccessible
}
fun isNetworkAvailable(context: Context): Boolean {
private fun isNetworkAccessible(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {

View File

@ -1,73 +1,72 @@
package bou.amine.apps.readerforselfossv2.android.utils.persistence
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Item
import bou.amine.apps.readerforselfossv2.android.api.selfoss.SelfossTagType
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Source
import bou.amine.apps.readerforselfossv2.android.api.selfoss.Tag
import bou.amine.apps.readerforselfossv2.android.persistence.entities.ItemEntity
import bou.amine.apps.readerforselfossv2.android.model.getSourceTitle
import bou.amine.apps.readerforselfossv2.android.model.getTitleDecoded
import bou.amine.apps.readerforselfossv2.android.persistence.entities.AndroidItemEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.SourceEntity
import bou.amine.apps.readerforselfossv2.android.persistence.entities.TagEntity
import bou.amine.apps.readerforselfossv2.rest.SelfossModel
fun TagEntity.toView(): Tag =
Tag(
fun TagEntity.toView(): SelfossModel.Tag =
SelfossModel.Tag(
this.tag,
this.color,
this.unread
)
fun SourceEntity.toView(): Source =
Source(
fun SourceEntity.toView(): SelfossModel.Source =
SelfossModel.Source(
this.id,
this.title,
SelfossTagType(this.tags),
this.tags.split(","),
this.spout,
this.error,
this.icon
)
fun Source.toEntity(): SourceEntity =
fun SelfossModel.Source.toEntity(): SourceEntity =
SourceEntity(
this.id,
this.getTitleDecoded(),
this.tags.tags,
this.tags.joinToString(","),
this.spout,
this.error,
this.icon.orEmpty()
this.icon
)
fun Tag.toEntity(): TagEntity =
fun SelfossModel.Tag.toEntity(): TagEntity =
TagEntity(
this.tag,
this.color,
this.unread
)
fun ItemEntity.toView(): Item =
Item(
fun AndroidItemEntity.toView(): SelfossModel.Item =
SelfossModel.Item(
this.id,
this.datetime,
this.title,
this.content,
this.unread,
this.starred,
if (this.unread) 1 else 0,
if (this.starred) 1 else 0,
this.thumbnail,
this.icon,
this.link,
this.sourcetitle,
SelfossTagType(this.tags)
this.tags
)
fun Item.toEntity(): ItemEntity =
ItemEntity(
fun SelfossModel.Item.toEntity(): AndroidItemEntity =
AndroidItemEntity(
this.id,
this.datetime,
this.getTitleDecoded(),
this.content,
this.unread,
this.starred,
this.unread == 1,
this.starred == 1,
this.thumbnail,
this.icon,
this.link,
this.getSourceTitle(),
this.tags.tags
this.tags
)

View File

@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
classpath("com.android.tools.build:gradle:7.1.2")
classpath("com.android.tools.build:gradle:7.2.0")
}
}

View File

@ -1,6 +1,6 @@
#Wed Feb 09 17:05:19 CET 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@ -20,9 +20,12 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:1.6.7")
implementation("io.ktor:ktor-client-serialization:1.6.7")
implementation("io.ktor:ktor-client-core:2.0.1")
implementation("io.ktor:ktor-client-content-negotiation:2.0.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.1")
implementation("io.ktor:ktor-client-logging:2.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
implementation("io.ktor:ktor-client-auth:2.0.1")
implementation("org.jsoup:jsoup:1.14.3")
}
}
@ -34,7 +37,7 @@ kotlin {
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:1.6.7")
implementation("io.ktor:ktor-client-android:2.0.1")
}
}
val androidTest by getting {
@ -60,7 +63,7 @@ kotlin {
iosX64Test.dependsOn(this)
iosArm64Test.dependsOn(this)
dependencies {
implementation("io.ktor:ktor-client-ios:1.6.7")
implementation("io.ktor:ktor-client-ios:2.0.1")
}
//iosSimulatorArm64Test.dependsOn(this)
}

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,62 +1,77 @@
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,
suspend fun getItems(
type: String,
items: Int,
offset: Int,
tag: String? = "",
source: Long? = null,
search: String? = "",
updatedSince: String? = ""): List<SelfossModel.Item> =
updatedSince: String? = ""
): List<SelfossModel.Item>? =
client.get(url("/items")) {
parameter("username", userName)
parameter("password", password)
parameter("username", apiDetailsService.getUserName())
parameter("password", apiDetailsService.getPassword())
parameter("type", type)
parameter("tag", tag)
parameter("source", source)
@ -64,98 +79,118 @@ class SelfossApi {
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"))
suspend fun version(): SelfossModel.ApiVersion? =
client.get(url("/api/about")).body()
suspend fun markAsRead(id: String): SelfossModel.SuccessResponse =
suspend fun markAsRead(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/mark/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
)
).body()
suspend fun unmarkAsRead(id: String): SelfossModel.SuccessResponse =
suspend fun unmarkAsRead(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/unmark/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
)
).body()
suspend fun starr(id: String): SelfossModel.SuccessResponse =
suspend fun starr(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/starr/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
)
).body()
suspend fun unstarr(id: String): SelfossModel.SuccessResponse =
suspend fun unstarr(id: String): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/unstarr/$id"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
},
encodeInQuery = true
)
).body()
suspend fun markAllAsRead(ids: List<String>): SelfossModel.SuccessResponse =
suspend fun markAllAsRead(ids: List<String>): SelfossModel.SuccessResponse? =
client.submitForm(
url = url("/mark"),
formParameters = Parameters.build {
append("username", userName)
append("password", password)
append("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("ids[]", ids.joinToString(","))
},
encodeInQuery = true
)
).body()
suspend fun createSource(title: String, url: String, spout: String, tags: String, filter: String): SelfossModel.SuccessResponse =
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)
}
private 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("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("title", title)
append("url", url)
append("spout", spout)
@ -163,14 +198,20 @@ class SelfossApi {
append("filter", filter)
},
encodeInQuery = true
)
).body()
suspend fun createSource2(title: String, url: String, spout: String, tags: String, filter: String): SelfossModel.SuccessResponse =
private 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("username", apiDetailsService.getUserName())
append("password", apiDetailsService.getPassword())
append("title", title)
append("url", url)
append("spout", spout)
@ -178,11 +219,11 @@ class SelfossApi {
append("filter", filter)
},
encodeInQuery = true
)
).body()
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
}