Commit edfd4fc5 authored by Christophe Henry's avatar Christophe Henry Committed by Christophe Henry

Refactor subscriptions to use database

parent 65f48ded
......@@ -14,6 +14,11 @@ android {
versionCode 4
versionName "1.0.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/fr/chenry/android/freshrss/store/database/migrations".toString()]
}
}
}
buildTypes {
release {
......@@ -114,5 +119,5 @@ dependencies {
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
// Debug
// implementation "com.facebook.stetho:stetho:1.5.0"
debugImplementation "com.facebook.stetho:stetho:1.5.0"
}
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "0a8440df04b9f0e8012be4f6c074c713",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `SID` TEXT NOT NULL, `Auth` TEXT NOT NULL, `login` TEXT NOT NULL, `serverInstance` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "SID",
"columnName": "SID",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "Auth",
"columnName": "Auth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "login",
"columnName": "login",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverInstance",
"columnName": "serverInstance",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "articles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `href` TEXT NOT NULL, `categories` TEXT NOT NULL, `author` TEXT NOT NULL, `content` TEXT NOT NULL, `streamId` TEXT NOT NULL, `readStatus` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamId",
"columnName": "streamId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "readStatus",
"columnName": "readStatus",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"0a8440df04b9f0e8012be4f6c074c713\")"
]
}
}
\ No newline at end of file
......@@ -8,6 +8,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.os.postDelayed
import fr.chenry.android.freshrss.RefresherService.RefresherBinder
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.utils.Try
import fr.chenry.android.freshrss.utils.whenNotNull
import nl.komponents.kovenant.android.startKovenant
......@@ -37,7 +38,7 @@ class FreshRSSApplication: Application() {
properties.getProperty("debug", "false")!!.toBoolean()
}.getOrDefault(false)
//if(Store.debugMode) Stetho.initializeWithDefaults(this)
//Stetho.initializeWithDefaults(this)
}
override fun onTerminate() {
......@@ -58,6 +59,8 @@ class FreshRSSApplication: Application() {
val stateSharedPreferences: SharedPreferences get() =
context.getSharedPreferences("STATE", Context.MODE_PRIVATE)
val database get() = FreshRSSDabatabase.instance
fun getStringR(id: Int) = application.resources.getString(id)
}
......
......@@ -35,7 +35,14 @@ class RefresherService: Service() {
val promise = Store.getSubscriptions()
.bind {Store.getUnreadCount()}
.bind {all(Store.subscriptions.keys.map {Store.getStreamContents(it)}, cancelOthersOnError = false)}
.bind {
FreshRSSApplication
.database
.getAllSubcriptionsIds()
.blockingFirst()
.map {Store.getStreamContents(it)}
.let {all(it, cancelOthersOnError = false)}
}
.bind {Store.getUnreadItems()}
Store.refreshingPromise.postValue(promise)
......
......@@ -20,7 +20,8 @@ import fr.chenry.android.freshrss.store.databindingsupport.viewmodels.Subscripti
import fr.chenry.android.freshrss.utils.capitalizeFull
import fr.chenry.android.freshrss.utils.e
import kotlinx.android.synthetic.main.fragment_subscription_article_detail.*
import nl.komponents.kovenant.ui.*
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi
import java.util.regex.Pattern
import kotlin.text.RegexOption.IGNORE_CASE
......@@ -127,15 +128,6 @@ class SubscriptionArticlesDetailFragment: Fragment() {
isFetching.value = true
Store.postReadStatus(article, readStatus)
.successUi {
Store.subscriptions[article.streamId]?.let {
it.unreadCount = when(readStatus) {
ReadStatus.READ -> Math.max(it.unreadCount - 1, 0)
ReadStatus.UNREAD -> it.unreadCount + 1
}
Store.subscriptions.emitUi()
}
}
.failUi {e ->
this.e(e)
......
......@@ -33,7 +33,6 @@ class MainSubscriptionFragment: Fragment(), BottomNavigationView.OnNavigationIte
subcription_fragment_container.offscreenPageLimit = SubscriptionSection.values().size
Store.subscriptionsSection.value!!.let {
Store.subscriptionsSection.value = it
subcription_bottom_navigation.selectedItemId = it.navigationButtonId
subcription_fragment_container.currentItem = it.ordinal
}
......
package fr.chenry.android.freshrss.components.subscriptions
import android.view.*
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView
import fr.chenry.android.freshrss.BR
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.databinding.FragmentSubscriptionBinding
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.database.models.Subscription
class RecyclerViewAdapter(private val fragment: SubscriptionsFragment):
RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder>()
......
......@@ -12,7 +12,7 @@ enum class SubscriptionSection(val navigationButtonId: Int): Parcelable {
companion object {
fun byPosition(position: Int) = SubscriptionSection.values().let {
if(position > it.size) ALL else it[position]
if(position > it.size - 1) ALL else it[position]
}
fun fromNavigationButton(buttonId: Int) = when(buttonId) {
......
......@@ -12,9 +12,7 @@ import fr.chenry.android.freshrss.MainNavDirections
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.components.subscriptions.SubscriptionSection.*
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.api.models.Subscriptions
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ
import fr.chenry.android.freshrss.store.databindingsupport.viewmodels.*
import kotlinx.android.synthetic.main.fragment_subscriptions.*
......
package fr.chenry.android.freshrss.store
import androidx.lifecycle.MutableLiveData
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.components.subscriptions.SubscriptionSection
import fr.chenry.android.freshrss.store.api.Api
import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ
import fr.chenry.android.freshrss.store.database.models.ReadStatus.UNREAD
import fr.chenry.android.freshrss.store.databindingsupport.GenericLiveData
import fr.chenry.android.freshrss.utils.e
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
......@@ -17,11 +16,9 @@ object Store {
lateinit var api: Api
private set
var debugMode = false
val subscriptions = GenericLiveData<StreamId, Subscription>()
val totalUnreadCount = MutableLiveData<Int>()
val subscriptionsSection = MutableLiveData<SubscriptionSection>().apply {this.value = SubscriptionSection.ALL}
val tags = MutableLiveData<List<String>>().apply {this.value = listOf()}
val favorites = GenericLiveData<String, Subscription>(mutableMapOf())
val refreshingPromise = MutableLiveData<Promise<Unit, Exception>>()
private var lastFetchTimestamp = 0L
......@@ -41,20 +38,17 @@ object Store {
.toSuccessVoid()
}
fun getSubscriptions(): Promise<Unit, Exception> =
api.getSubscriptions() then {subscriptions.addAll(it.map {self -> self.id to self})}
fun getSubscriptions(): Promise<Unit, Exception> = api.getSubscriptions() then {
val subscriptions = it.map {self -> Subscription.fromSubscriptionApiItem(self)}
FreshRSSApplication.database.syncSubscriptions(subscriptions)
}
fun getUnreadCount(): Promise<Unit, Exception> =
api.getUnreadCount() then {
totalUnreadCount.postValue(it.max)
if(!subscriptions.isNullOrEmpty())
it.unreadcounts.forEach {self ->
subscriptions[self.id]?.let {subscription ->
subscription.unreadCount = self.count
subscription.newestItemDate = self.newestItemTimestampUsec
}
}
subscriptions.emit()
it.unreadcounts.forEach {self ->
FreshRSSApplication.database.updateSubscriptionCount(self.id, self.count)
}
}
fun getStreamItems(id: String): Promise<Unit, Exception> =
......@@ -84,6 +78,14 @@ object Store {
fun postReadStatus(article: Article, readStatus: ReadStatus): Promise<Unit, Exception> =
ensureToken() bind {
api.postReadStatus(article.id, readStatus)
.success {FreshRSSDabatabase.instance.upsertArticle(article.copy(readStatus = readStatus))}
.success {
FreshRSSApplication.database.apply {
upsertArticle(article.copy(readStatus = readStatus))
when(readStatus) {
READ -> decrementSubscriptionCount(article.streamId)
UNREAD -> incrementSubscriptionCount(article.streamId)
}
}
}
}
}
\ No newline at end of file
......@@ -2,11 +2,7 @@ package fr.chenry.android.freshrss.store.api
import com.github.kittinunf.fuel.Fuel
import fr.chenry.android.freshrss.store.api.models.*
import fr.chenry.android.freshrss.store.database.models.Account
import fr.chenry.android.freshrss.store.api.models.Subscriptions
import fr.chenry.android.freshrss.store.api.models.SubscriptionsHandler
import fr.chenry.android.freshrss.store.database.models.ItemId
import fr.chenry.android.freshrss.store.database.models.ReadStatus
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.*
import nl.komponents.kovenant.functional.bind
......@@ -20,7 +16,7 @@ class Api(private val account: Account) {
.header("Authorization" to "GoogleLogin auth=${account.SID}")
.promiseString()
fun getSubscriptions(): Promise<Subscriptions, Exception> =
fun getSubscriptions(): Promise<List<SubscriptionApiItem>, Exception> =
Fuel
.getJson(endpoints.subscriptionEndpoint)
.authentify(this.account)
......
package fr.chenry.android.freshrss.store.api.models
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.deser.std.StringDeserializer
import fr.chenry.android.freshrss.BR
import fr.chenry.android.freshrss.utils.unescapeHtml4
import org.joda.time.LocalDateTime
typealias StreamId = String
data class SubscriptionsHandler(val subscriptions: Subscriptions)
data class SubscriptionsHandler(val subscriptions: List<SubscriptionApiItem>)
typealias Subscriptions = List<Subscription>
data class Subscription(
data class SubscriptionApiItem(
val id: String,
@JsonDeserialize(using = HtmlEntitiesDeserializer::class)
val title: String,
val categories: List<SubscriptionCategory>,
val url: String,
val htmlUrl: String
): BaseObservable() {
@Transient @get:Bindable
var unreadCount = 0
set(value) {
field = value
notifyPropertyChanged(BR.unreadCount)
}
@Transient
var newestItemDate: LocalDateTime = LocalDateTime(0)
}
)
data class SubscriptionCategory(
val id: String,
......
package fr.chenry.android.freshrss.store.database
import android.database.sqlite.SQLiteConstraintException
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.store.database.models.ReadStatus.READ
import fr.chenry.android.freshrss.store.database.models.ReadStatus.UNREAD
import fr.chenry.android.freshrss.utils.Try
import fr.chenry.android.freshrss.utils.getOrDefault
import nl.komponents.kovenant.task
import kotlin.reflect.KProperty
@Database(version = 1, entities = [Account::class, Article::class], exportSchema = false)
@Database(version = 2, entities = [Account::class, Article::class, Subscription::class])
@TypeConverters(Converters::class)
abstract class FreshRSSDabatabase: RoomDatabase() {
private val authTokensDelegate = AuthTokensDelegate()
......@@ -21,14 +21,34 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
protected abstract fun getAuthTokensDAO(): AuthTokensDAO
protected abstract fun getArticlesDAO(): ArticlesDAO
protected abstract fun getSubscriptionsDAO(): SubscriptionsDAO
fun getArticleByStreamId(streamId: StreamId) = getArticlesDAO().getByStreamId(streamId)
fun getArticleById(id: ItemId) = getArticlesDAO().getById(id)
fun getArticleByStreamIdAndUnread(streamId: StreamId) =
getArticlesDAO().getByStreamIdAndUnread(streamId, ReadStatus.UNREAD.name)
fun upsertArticle(article: Article) =
try {getArticlesDAO().update(article)} catch(_: SQLiteConstraintException) {getArticlesDAO().insert(article)}
Try{getArticlesDAO().update(article)}.orElseDo {getArticlesDAO().insert(article)}
fun insertArticle(article: Article) = getArticlesDAO().forceInsert(article)
fun syncSubscriptions(subscriptions: Subscriptions) {
task {
getSubscriptionsDAO().insertAll(subscriptions)
}
task {
getSubscriptionsDAO()
.getAllIds()
.blockingFirst()
.subtract(subscriptions.map {it.id})
.let {getSubscriptionsDAO().deleteAllById(it.toList())}
}
}
fun updateSubscriptionCount(id: String, count: Int) = getSubscriptionsDAO().updateCount(id, count)
fun incrementSubscriptionCount(id: String) = getSubscriptionsDAO().incrementCount(id)
fun decrementSubscriptionCount(id: String) = getSubscriptionsDAO().decrementCount(id)
fun getAllSubcriptions() = getSubscriptionsDAO().getAll()
fun getAllSubcriptionsIds() = getSubscriptionsDAO().getAllIds()
companion object {
private val dbName by lazy {
......@@ -36,12 +56,17 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
}
val instance by lazy {
val instance =
Room.databaseBuilder(FreshRSSApplication.context, FreshRSSDabatabase::class.java, dbName)
.fallbackToDestructiveMigration()
Room
.databaseBuilder(FreshRSSApplication.context, FreshRSSDabatabase::class.java, dbName)
.addMigrations(MIGRATION_1_2)
.build()
instance.authTokensDelegate.fetchAuthtokensFromDB()
instance
}
private val MIGRATION_1_2 = object: Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) = Unit
}
}
inner class AuthTokensDelegate {
......@@ -53,7 +78,7 @@ abstract class FreshRSSDabatabase: RoomDatabase() {
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Account) {
cachedAccount = value
task { getAuthTokensDAO().insert(account) }.getOrDefault(Unit)
task {getAuthTokensDAO().insert(account)}.getOrDefault(Unit)
}
fun fetchAuthtokensFromDB() {
......
......@@ -25,7 +25,6 @@ data class Account(
@Ignore
private var writeTokenBirth = LocalDateTime.now()
val isWriteTokenExpired get() = writeTokenBirth.isBefore(LocalDateTime.now().minusMinutes(30))
val canPostRequests get() = writeToken.isNotBlank()
companion object: ResponseDeserializable<Account> {
override fun deserialize(content: String): Account {
......
......@@ -7,6 +7,7 @@ import androidx.lifecycle.LiveData
import androidx.room.*
import fr.chenry.android.freshrss.store.api.models.ContentItem
import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.utils.Try
import io.reactivex.Flowable
import kotlinx.android.parcel.Parcelize
......@@ -27,11 +28,7 @@ data class Article(
val readStatus: ReadStatus = ReadStatus.READ
): BaseObservable() {
@Ignore
val url = try {
Uri.parse(href)
} catch(_: Throwable) {
null
}
val url = Try{Uri.parse(href)}.getOrNull()
companion object {
fun fromContentItem(item: ContentItem) = Article(
......
package fr.chenry.android.freshrss.store.database.models
import androidx.databinding.BaseObservable
import androidx.room.*
import fr.chenry.android.freshrss.store.api.models.SubscriptionApiItem
import io.reactivex.Flowable
import org.joda.time.LocalDateTime
typealias Subscriptions = List<Subscription>
@Entity(tableName = "subscriptions")
data class Subscription(
@PrimaryKey
val id: String,
val title: String,
val unreadCount: Int = 0
): BaseObservable() {
@Ignore
var newestItemDate: LocalDateTime = LocalDateTime(0)
companion object {
fun fromSubscriptionApiItem(subscriptionApiItem: SubscriptionApiItem) = Subscription(
subscriptionApiItem.id,
subscriptionApiItem.title
)
}
}
@Dao
interface SubscriptionsDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(subscriptions: Subscriptions)
@Query("SELECT id FROM subscriptions")
fun getAllIds(): Flowable<List<String>>
@Query("SELECT * FROM subscriptions")
fun getAll(): Flowable<Subscriptions>
@Query("SELECT * FROM subscriptions where id = :id")
fun byId(id: String): Flowable<Subscription>
@Query("DELETE FROM subscriptions WHERE id IN (:ids)")
fun deleteAllById(ids: List<String>)
@Query("UPDATE subscriptions SET unreadCount = :count WHERE id = :id")
fun updateCount(id: String, count: Int)
@Query("UPDATE subscriptions SET unreadCount = unreadCount + 1 WHERE id = :id")
fun incrementCount(id: String)
@Query("UPDATE subscriptions SET unreadCount = unreadCount - 1 WHERE id = :id AND unreadCount > 0")
fun decrementCount(id: String)
}
\ No newline at end of file
package fr.chenry.android.freshrss.store.databindingsupport.viewmodels
import androidx.lifecycle.*
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.api.models.Subscriptions
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.store.database.models.Subscription
import fr.chenry.android.freshrss.store.database.models.Subscriptions
sealed class SubscriptionsVM: ViewModel() {
protected val subscriptionsLiveData = FreshRSSApplication.database.getAllSubcriptions().toLiveData()
val liveData: LiveData<Subscriptions> = MutableLiveData<Subscriptions>().apply {
value = load()
Store.subscriptions.observeForever {value = load()}
value = load() ?: listOf()
subscriptionsLiveData.observeForever {value = load() ?: listOf()}
}
protected abstract fun load(): Subscriptions
protected abstract fun load(): Subscriptions?
}
class AllSubscriptionsVM: SubscriptionsVM() {
override fun load() = Store.subscriptions.values.sortedWith(Comparator {o1, o2 ->
override fun load() = subscriptionsLiveData.value?.sortedWith(Comparator {o1, o2 ->
o1.title.compareTo(o2.title, true)
})
}
class UnreadSubscriptionsVM: SubscriptionsVM() {
override fun load() = Store.subscriptions.values.filter {it.unreadCount > 0}.sortedBy {it.newestItemDate}
override fun load() = subscriptionsLiveData.value?.filter {it.unreadCount > 0}?.sortedBy {it.newestItemDate}
}
class FavoritesSubscriptionsVM: SubscriptionsVM() {
......
......@@ -19,5 +19,7 @@ class Try<T: Any>(body: () -> T) {
fun <U: Any>map(body: (Try<T>) -> U): Try<U> = Try{body(this)}
fun <U: Any>whenSuccess(body: (T) -> U): Try<T> = if(isSuccess) body(value).let{this} else this
fun <U: Any>whenError(body: (Throwable) -> U): Try<T> = if(!isSuccess) body(error).let{this} else this
fun orElseDo(body: () -> Unit) = if(!isSuccess) body() else Unit
fun getOrDefault(default: T) = if(isSuccess) value else default
fun getOrNull() = if(isSuccess) value else null
}
\ No newline at end of file
......@@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="subscription" type="fr.chenry.android.freshrss.store.api.models.Subscription"/>
<variable name="subscription" type="fr.chenry.android.freshrss.store.database.models.Subscription"/>
</data>
<LinearLayout
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment