Migrate database to new version

* Rename some Account fields
* Migrate Account.type enum field from INTEGER to TEXT
This commit is contained in:
Shinokuni 2024-10-08 17:59:32 +02:00
parent fb68f1f492
commit 4753454a9d
30 changed files with 646 additions and 95 deletions

View File

@ -12,9 +12,9 @@ abstract class Credentials(val authorization: String?, val url: String) {
companion object { companion object {
fun toCredentials(account: Account): Credentials { fun toCredentials(account: Account): Credentials {
val endPoint = getEndPoint(account.accountType!!) val endPoint = getEndPoint(account.type!!)
return when (account.accountType) { return when (account.type) {
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint) AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint)
AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint) AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint)
AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint) AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)

View File

@ -22,7 +22,7 @@ class GetFoldersWithFeedsTest {
private lateinit var database: Database private lateinit var database: Database
private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds
private val account = Account(accountType = AccountType.LOCAL) private val account = Account(type = AccountType.LOCAL)
@Before @Before
fun before() { fun before() {

View File

@ -32,7 +32,7 @@ import kotlin.test.assertTrue
class LocalRSSRepositoryTest : KoinTest { class LocalRSSRepositoryTest : KoinTest {
private val mockServer: MockWebServer = MockWebServer() private val mockServer: MockWebServer = MockWebServer()
private val account = Account(accountType = AccountType.LOCAL) private val account = Account(type = AccountType.LOCAL)
private lateinit var database: Database private lateinit var database: Database
private lateinit var repository: LocalRSSRepository private lateinit var repository: LocalRSSRepository
private lateinit var feeds: List<Feed> private lateinit var feeds: List<Feed>

View File

@ -32,20 +32,20 @@ class SyncAnalyzerTest {
NullPointerException("Notification content shouldn't be null") NullPointerException("Notification content shouldn't be null")
private val account1 = Account( private val account1 = Account(
accountName = "test account 1", name = "test account 1",
accountType = AccountType.FRESHRSS, type = AccountType.FRESHRSS,
isNotificationsEnabled = true isNotificationsEnabled = true
) )
private val account2 = Account( private val account2 = Account(
accountName = "test account 2", name = "test account 2",
accountType = AccountType.NEXTCLOUD_NEWS, type = AccountType.NEXTCLOUD_NEWS,
isNotificationsEnabled = false isNotificationsEnabled = false
) )
private val account3 = Account( private val account3 = Account(
accountName = "test account 3", name = "test account 3",
accountType = AccountType.LOCAL, type = AccountType.LOCAL,
isNotificationsEnabled = true isNotificationsEnabled = true
) )
@ -124,7 +124,7 @@ class SyncAnalyzerTest {
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content -> syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
assertEquals(context.getString(R.string.new_items, 2), content.text) assertEquals(context.getString(R.string.new_items, 2), content.text)
assertEquals(account1.accountName, content.title) assertEquals(account1.name, content.title)
assertTrue(content.largeIcon != null) assertTrue(content.largeIcon != null)
assertTrue(content.accountId > 0) assertTrue(content.accountId > 0)
} ?: throw nullContentException } ?: throw nullContentException

View File

@ -59,7 +59,7 @@ val appModule = module {
single { GetFoldersWithFeeds(get()) } single { GetFoldersWithFeeds(get()) }
factory<BaseRepository> { (account: Account) -> factory<BaseRepository> { (account: Account) ->
when (account.accountType) { when (account.type) {
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account) AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
AccountType.FRESHRSS -> FreshRSSRepository( AccountType.FRESHRSS -> FreshRSSRepository(
database = get(), database = get(),

View File

@ -178,8 +178,8 @@ class AccountScreenModel(
fun createLocalAccount() { fun createLocalAccount() {
val context = get<Context>() val context = get<Context>()
val account = Account( val account = Account(
accountName = context.getString(AccountType.LOCAL.typeName), name = context.getString(AccountType.LOCAL.nameRes),
accountType = AccountType.LOCAL, type = AccountType.LOCAL,
isCurrentAccount = true isCurrentAccount = true
) )
@ -212,7 +212,7 @@ class AccountScreenModel(
@Stable @Stable
data class AccountState( data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL), val account: Account = Account(name = "account", type = AccountType.LOCAL),
val dialog: DialogState? = null, val dialog: DialogState? = null,
val synchronizationErrors: ErrorResult? = null, val synchronizationErrors: ErrorResult? = null,
val error: Exception? = null, val error: Exception? = null,

View File

@ -192,7 +192,7 @@ object AccountTab : Tab {
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Image( Image(
painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes), painter = adaptiveIconPainterResource(id = state.account.type!!.iconRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
@ -201,7 +201,7 @@ object AccountTab : Tab {
Column { Column {
Text( Text(
text = state.account.accountName!!, text = state.account.name!!,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -224,7 +224,7 @@ object AccountTab : Tab {
ThreeDotsMenu( ThreeDotsMenu(
items = mapOf(1 to stringResource(id = R.string.rename_account)), items = mapOf(1 to stringResource(id = R.string.rename_account)),
onItemClick = { onItemClick = {
screenModel.openDialog(DialogState.RenameAccount(state.account.accountName!!)) screenModel.openDialog(DialogState.RenameAccount(state.account.name!!))
}, },
) )
} }
@ -303,8 +303,8 @@ object AccountTab : Tab {
for (account in state.accounts) { for (account in state.accounts) {
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = account.accountType!!.iconRes), image = adaptiveIconPainterResource(id = account.type!!.iconRes),
text = account.accountName!!, text = account.name!!,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
@ -358,8 +358,8 @@ object AccountTab : Tab {
screenModel.createLocalAccount() screenModel.createLocalAccount()
} else { } else {
val account = Account( val account = Account(
accountType = accountType, type = accountType,
accountName = context.resources.getString(accountType.typeName) name = context.resources.getString(accountType.nameRes)
) )
navigator.push( navigator.push(
AccountCredentialsScreen( AccountCredentialsScreen(

View File

@ -117,7 +117,7 @@ class AccountCredentialsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Image( Image(
painter = adaptiveIconPainterResource(id = account.accountType!!.iconRes), painter = adaptiveIconPainterResource(id = account.type!!.iconRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
@ -125,7 +125,7 @@ class AccountCredentialsScreen(
ShortSpacer() ShortSpacer()
Text( Text(
text = stringResource(id = account.accountType!!.typeName), text = stringResource(id = account.type!!.nameRes),
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
) )
@ -155,7 +155,7 @@ class AccountCredentialsScreen(
state.urlError != null -> { state.urlError != null -> {
Text(text = state.urlError!!.errorText()) Text(text = state.urlError!!.errorText())
} }
account.accountType == AccountType.FEVER -> { account.type == AccountType.FEVER -> {
Text(text = stringResource(R.string.provide_full_url)) Text(text = stringResource(R.string.provide_full_url))
} }
else -> { else -> {
@ -211,7 +211,7 @@ class AccountCredentialsScreen(
state.passwordError != null -> { state.passwordError != null -> {
Text(text = state.passwordError!!.errorText()) Text(text = state.passwordError!!.errorText())
} }
account.accountType == AccountType.FRESHRSS -> { account.type == AccountType.FRESHRSS -> {
Text(text = stringResource(id = R.string.password_helper)) Text(text = stringResource(id = R.string.password_helper))
} }
} }

View File

@ -28,14 +28,14 @@ class AccountCredentialsScreenModel(
if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) { if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) {
mutableState.update { mutableState.update {
it.copy( it.copy(
name = account.accountName!!, name = account.name!!,
url = account.url!!, url = account.url!!,
login = account.login!!, login = account.login!!,
password = account.password!! password = account.password!!
) )
} }
} else { } else {
mutableState.update { it.copy(name = account.accountName!!) } mutableState.update { it.copy(name = account.name!!) }
} }
} }
@ -61,10 +61,10 @@ class AccountCredentialsScreenModel(
val newAccount = account.copy( val newAccount = account.copy(
url = normalizedUrl, url = normalizedUrl,
accountName = name, name = name,
login = login, login = login,
password = password, password = password,
accountType = account.accountType, type = account.type,
isCurrentAccount = true isCurrentAccount = true
) )

View File

@ -22,11 +22,10 @@ fun AccountSelectionDialog(
onDismiss = onDismiss onDismiss = onDismiss
) { ) {
AccountType.entries AccountType.entries
.filter { it != AccountType.FEEDLY }
.forEach { type -> .forEach { type ->
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = type.iconRes), image = adaptiveIconPainterResource(id = type.iconRes),
text = stringResource(id = type.typeName), text = stringResource(id = type.nameRes),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.shortSpacing, padding = MaterialTheme.spacing.shortSpacing,

View File

@ -95,8 +95,8 @@ class AccountSelectionScreen : AndroidScreen() {
val accountType = val accountType =
(state.navState as NavState.GoToAccountCredentialsScreen).accountType (state.navState as NavState.GoToAccountCredentialsScreen).accountType
val account = Account( val account = Account(
accountType = accountType, type = accountType,
accountName = stringResource(id = accountType.typeName) name = stringResource(id = accountType.nameRes)
) )
navigator.push( navigator.push(
@ -159,7 +159,7 @@ class AccountSelectionScreen : AndroidScreen() {
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = AccountType.LOCAL.typeName), text = stringResource(id = AccountType.LOCAL.nameRes),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
@ -187,11 +187,10 @@ class AccountSelectionScreen : AndroidScreen() {
AccountType.entries AccountType.entries
.filter { it != AccountType.LOCAL } .filter { it != AccountType.LOCAL }
.filter { it != AccountType.FEEDLY }
.forEach { accountType -> .forEach { accountType ->
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = accountType.iconRes), image = adaptiveIconPainterResource(id = accountType.iconRes),
text = stringResource(id = accountType.typeName), text = stringResource(id = accountType.nameRes),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
imageSize = 24.dp, imageSize = 24.dp,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,

View File

@ -55,8 +55,8 @@ class AccountSelectionScreenModel(
val context = get<Context>() val context = get<Context>()
val account = Account( val account = Account(
url = null, url = null,
accountName = context.getString(AccountType.LOCAL.typeName), name = context.getString(AccountType.LOCAL.nameRes),
accountType = AccountType.LOCAL, type = AccountType.LOCAL,
isCurrentAccount = true isCurrentAccount = true
) )

View File

@ -41,7 +41,7 @@ sealed class FolderAndFeedsState {
data class AddFeedDialogState( data class AddFeedDialogState(
val url: String = "", val url: String = "",
val selectedAccount: Account = Account(accountName = ""), val selectedAccount: Account = Account(name = ""),
val accounts: List<Account> = listOf(), val accounts: List<Account> = listOf(),
val error: TextFieldError? = null, val error: TextFieldError? = null,
val exception: Exception? = null, val exception: Exception? = null,

View File

@ -79,14 +79,14 @@ fun AddFeedDialog(
) { ) {
for (account in state.accounts) { for (account in state.accounts) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(text = account.accountName!!) }, text = { Text(text = account.name!!) },
onClick = { onClick = {
onAccountClick(account) onAccountClick(account)
}, },
leadingIcon = { leadingIcon = {
Image( Image(
painter = adaptiveIconPainterResource( painter = adaptiveIconPainterResource(
id = account.accountType!!.iconRes id = account.type!!.iconRes
), ),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
@ -97,7 +97,7 @@ fun AddFeedDialog(
} }
OutlinedTextField( OutlinedTextField(
value = state.selectedAccount.accountName!!, value = state.selectedAccount.name!!,
readOnly = true, readOnly = true,
onValueChange = {}, onValueChange = {},
trailingIcon = { trailingIcon = {
@ -106,7 +106,7 @@ fun AddFeedDialog(
leadingIcon = { leadingIcon = {
Image( Image(
painter = adaptiveIconPainterResource( painter = adaptiveIconPainterResource(
id = state.selectedAccount.accountType!!.iconRes id = state.selectedAccount.type!!.iconRes
), ),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)

View File

@ -52,7 +52,7 @@ class ItemScreenModel(
.flatMapLatest { account -> .flatMapLatest { account ->
this@ItemScreenModel.account = account!! this@ItemScreenModel.account = account!!
if (account.accountType == AccountType.FEVER) { if (account.type == AccountType.FEVER) {
get<SharedPreferences>().apply { get<SharedPreferences>().apply {
account.login = getString(account.loginKey, null) account.login = getString(account.loginKey, null)
account.password = getString(account.passwordKey, null) account.password = getString(account.passwordKey, null)

View File

@ -25,7 +25,7 @@ class FeverRepository(
val authenticated = feverDataSource.login(account.login!!, account.password!!) val authenticated = feverDataSource.login(account.login!!, account.password!!)
if (authenticated) { if (authenticated) {
account.displayedName = account.accountType!!.name account.displayedName = account.type!!.name
} else { } else {
throw LoginFailedException() throw LoginFailedException()
} }

View File

@ -115,7 +115,7 @@ class LocalRSSRepository(
private suspend fun insertFeed(feed: Feed): Feed { private suspend fun insertFeed(feed: Feed): Feed {
// TODO better handle this case // TODO better handle this case
require(!database.feedDao().feedExists(feed.url!!, account.id)) { require(!database.feedDao().feedExists(feed.url!!, account.id)) {
"Feed already exists for account ${account.accountName}" "Feed already exists for account ${account.name}"
} }
return feed.apply { return feed.apply {

View File

@ -67,11 +67,11 @@ class SyncAnalyzer(
// multiple new items from several feeds // multiple new items from several feeds
feedsIdsForNewItems.size > 1 && itemCount > 1 -> { feedsIdsForNewItems.size > 1 && itemCount > 1 -> {
NotificationContent( NotificationContent(
title = account.accountName!!, title = account.name!!,
text = context.getString(R.string.new_items, itemCount.toString()), text = context.getString(R.string.new_items, itemCount.toString()),
largeIcon = ContextCompat.getDrawable( largeIcon = ContextCompat.getDrawable(
context, context,
account.accountType!!.iconRes account.type!!.iconRes
)!!.toBitmap(), )!!.toBitmap(),
accountId = account.id accountId = account.id
) )

View File

@ -131,7 +131,7 @@ class SyncWorker(
notificationBuilder.setContentTitle( notificationBuilder.setContentTitle(
applicationContext.resources.getString( applicationContext.resources.getString(
R.string.updating_account, R.string.updating_account,
account.accountName account.name
) )
) )
@ -212,7 +212,7 @@ class SyncWorker(
if (result.second.isNotEmpty()) { if (result.second.isNotEmpty()) {
Log.e( Log.e(
TAG, TAG,
"refreshing local account ${account.accountName}: ${result.second.size} errors" "refreshing local account ${account.name}: ${result.second.size} errors"
) )
} }

View File

@ -0,0 +1,526 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "0bac941f8b1b6003c35a6d0cdc1f2e13",
"entities": [
{
"tableName": "Feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `url` TEXT, `siteUrl` TEXT, `last_updated` TEXT, `color` INTEGER NOT NULL, `icon_url` TEXT, `etag` TEXT, `last_modified` TEXT, `folder_id` INTEGER, `remote_id` TEXT, `account_id` INTEGER NOT NULL, `notification_enabled` INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(`folder_id`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "siteUrl",
"columnName": "siteUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "iconUrl",
"columnName": "icon_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "folderId",
"columnName": "folder_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isNotificationEnabled",
"columnName": "notification_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Feed_folder_id",
"unique": false,
"columnNames": [
"folder_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Feed_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
},
{
"name": "index_Feed_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Feed_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "Folder",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"folder_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "Account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `description` TEXT, `clean_description` TEXT, `link` TEXT, `image_link` TEXT, `author` TEXT, `pub_date` INTEGER, `content` TEXT, `feed_id` INTEGER NOT NULL, `read_time` REAL NOT NULL, `read` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `remote_id` TEXT, FOREIGN KEY(`feed_id`) REFERENCES `Feed`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "cleanDescription",
"columnName": "clean_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "imageLink",
"columnName": "image_link",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pubDate",
"columnName": "pub_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "feedId",
"columnName": "feed_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "readTime",
"columnName": "read_time",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "read",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Item_feed_id",
"unique": false,
"columnNames": [
"feed_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Item_feed_id` ON `${TABLE_NAME}` (`feed_id`)"
}
],
"foreignKeys": [
{
"table": "Feed",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"feed_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Folder",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `remoteId` TEXT, `account_id` INTEGER NOT NULL, FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "remoteId",
"columnName": "remoteId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Folder_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Folder_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "Account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Account",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT, `name` TEXT, `displayed_name` TEXT, `type` TEXT, `last_modified` INTEGER NOT NULL, `current_account` INTEGER NOT NULL, `token` TEXT, `write_token` TEXT, `notifications_enabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayedName",
"columnName": "displayed_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isCurrentAccount",
"columnName": "current_account",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "writeToken",
"columnName": "write_token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isNotificationsEnabled",
"columnName": "notifications_enabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ItemStateChange",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `read_change` INTEGER NOT NULL, `star_change` INTEGER NOT NULL, `account_id` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "readChange",
"columnName": "read_change",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starChange",
"columnName": "star_change",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "Account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemState",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `read` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `remote_id` TEXT NOT NULL, `account_id` INTEGER NOT NULL, FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "read",
"columnName": "read",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ItemState_remote_id_account_id",
"unique": true,
"columnNames": [
"remote_id",
"account_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemState_remote_id_account_id` ON `${TABLE_NAME}` (`remote_id`, `account_id`)"
}
],
"foreignKeys": [
{
"table": "Account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, '0bac941f8b1b6003c35a6d0cdc1f2e13')"
]
}
}

View File

@ -54,4 +54,16 @@ class MigrationsTest {
assertEquals("guid", remoteId) assertEquals("guid", remoteId)
} }
} }
@Test
fun migrate4To5() {
helper.createDatabase(dbName, 4).apply {
execSQL("Insert Into Account(account_type, last_modified, current_account, notifications_enabled) Values(0, 0, 1, 0)")
}
helper.runMigrationsAndValidate(dbName, 5, true, MigrationFrom4To5).apply {
val type = compileStatement("Select type From Account").simpleQueryForString()
assertEquals("LOCAL", type)
}
}
} }

View File

@ -28,7 +28,7 @@ class FeedDaoTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build() database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
account = Account(accountType = AccountType.LOCAL).apply { account = Account(type = AccountType.LOCAL).apply {
id = database.accountDao().insert(this).toInt() id = database.accountDao().insert(this).toInt()
} }

View File

@ -28,7 +28,7 @@ class FolderDaoTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build() database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
account = Account(accountType = AccountType.LOCAL).apply { account = Account(type = AccountType.LOCAL).apply {
id = database.accountDao().insert(this).toInt() id = database.accountDao().insert(this).toInt()
} }

View File

@ -17,12 +17,13 @@ import com.readrops.db.entities.Item
import com.readrops.db.entities.ItemState import com.readrops.db.entities.ItemState
import com.readrops.db.entities.ItemStateChange import com.readrops.db.entities.ItemStateChange
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import com.readrops.db.util.Converters import com.readrops.db.util.Converters
@Database( @Database(
entities = [Feed::class, Item::class, Folder::class, Account::class, entities = [Feed::class, Item::class, Folder::class, Account::class,
ItemStateChange::class, ItemState::class], ItemStateChange::class, ItemState::class],
version = 4 version = 5
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class Database : RoomDatabase() { abstract class Database : RoomDatabase() {
@ -96,5 +97,29 @@ object MigrationFrom3To4 : Migration(3, 4) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Feed_folder_id` ON `Feed` (`folder_id`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_Feed_folder_id` ON `Feed` (`folder_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Feed_account_id` ON `Feed` (`account_id`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_Feed_account_id` ON `Feed` (`account_id`)")
} }
}
object MigrationFrom4To5 : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// rename account_name to name
// rename account_type to type
// rename writeToken to write_token
db.execSQL("CREATE TABLE IF NOT EXISTS `_new_Account` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT, `name` TEXT, `displayed_name` TEXT, `type` TEXT, `last_modified` INTEGER NOT NULL, `current_account` INTEGER NOT NULL, `token` TEXT, `write_token` TEXT, `notifications_enabled` INTEGER NOT NULL)")
db.execSQL("INSERT INTO `_new_Account` (`id`, `url`, `name`, `displayed_name`, `type`, `last_modified`, `current_account`, `token`, `write_token`, `notifications_enabled`) SELECT `id`, `url`, `account_name`, `displayed_name`, NULL, `last_modified`, `current_account`, `token`, `writeToken`, `notifications_enabled` FROM `Account`")
// migrate type from INTEGER to TEXT
val cursor = db.query("SELECT `id`, `account_type` FROM `Account`")
while (cursor.moveToNext()) {
val id = cursor.getInt(0)
val ordinal = cursor.getInt(1)
val type = AccountType.entries[ordinal]
db.execSQL("UPDATE `_new_Account` SET `type` = \"${type.name}\" WHERE `id` = $id")
}
db.execSQL("DROP TABLE IF EXISTS `Account`")
db.execSQL("ALTER TABLE `_new_Account` RENAME TO `Account`")
}
} }

View File

@ -7,7 +7,12 @@ val dbModule = module {
single(createdAtStart = true) { single(createdAtStart = true) {
Room.databaseBuilder(get(), Database::class.java, "readrops-db") Room.databaseBuilder(get(), Database::class.java, "readrops-db")
.addMigrations(MigrationFrom1To2, MigrationFrom2To3, MigrationFrom3To4) .addMigrations(
MigrationFrom1To2,
MigrationFrom2To3,
MigrationFrom3To4,
MigrationFrom4To5
)
.build() .build()
} }
} }

View File

@ -46,6 +46,6 @@ interface AccountDao : BaseDao<Account> {
When id Is Not :accountId Then 0 End""") When id Is Not :accountId Then 0 End""")
suspend fun updateCurrentAccount(accountId: Int) suspend fun updateCurrentAccount(accountId: Int)
@Query("Update Account set account_name = :name Where id = :accountId") @Query("Update Account set name = :name Where id = :accountId")
suspend fun renameAccount(accountId: Int, name: String) suspend fun renameAccount(accountId: Int, name: String)
} }

View File

@ -8,34 +8,31 @@ import java.io.Serializable
@Entity @Entity
data class Account( data class Account(
@PrimaryKey(autoGenerate = true) var id: Int = 0, @PrimaryKey(autoGenerate = true) var id: Int = 0,
var url: String? = null, var url: String? = null,
@ColumnInfo(name = "account_name") var accountName: String? = null, @ColumnInfo(name = "name") var name: String? = null,
@ColumnInfo(name = "displayed_name") var displayedName: String? = null, @ColumnInfo(name = "displayed_name") var displayedName: String? = null,
@ColumnInfo(name = "account_type") var accountType: AccountType? = null, @ColumnInfo(name = "type") var type: AccountType? = null,
@ColumnInfo(name = "last_modified") var lastModified: Long = 0, @ColumnInfo(name = "last_modified") var lastModified: Long = 0,
@ColumnInfo(name = "current_account") var isCurrentAccount: Boolean = false, @ColumnInfo(name = "current_account") var isCurrentAccount: Boolean = false,
var token: String? = null, var token: String? = null,
var writeToken: String? = null, // TODO : see if there is a better solution to store specific service account fields @ColumnInfo(name = "write_token") var writeToken: String? = null,
@ColumnInfo(name = "notifications_enabled") var isNotificationsEnabled: Boolean = false, @ColumnInfo(name = "notifications_enabled") var isNotificationsEnabled: Boolean = false,
@Ignore var login: String? = null, @Ignore var login: String? = null,
@Ignore var password: String? = null, @Ignore var password: String? = null,
) : Serializable { ) : Serializable {
constructor(accountUrl: String?, accountName: String, accountType: AccountType):
this(url = accountUrl, accountName = accountName, accountType = accountType)
val config: AccountConfig val config: AccountConfig
get() = accountType!!.accountConfig!! get() = type!!.config
val isLocal val isLocal
get() = accountType == AccountType.LOCAL get() = type == AccountType.LOCAL
fun `is`(accountType: AccountType) = this.accountType == accountType fun `is`(accountType: AccountType) = this.type == accountType
val loginKey val loginKey
get() = accountType!!.name + "_login_" + id get() = type!!.name + "_login_" + id
val passwordKey val passwordKey
get() = accountType!!.name + "_password_" + id get() = type!!.name + "_password_" + id
} }

View File

@ -5,7 +5,7 @@ data class AccountConfig(
val addNoFolder: Boolean, // Add a "No folder" option when modifying a feed's folder val addNoFolder: Boolean, // Add a "No folder" option when modifying a feed's folder
val useSeparateState: Boolean, // Let know if it uses ItemState table to synchronize read/star state val useSeparateState: Boolean, // Let know if it uses ItemState table to synchronize read/star state
val canCreateFolder: Boolean, // Enable or disable folder creation in Feed Tab val canCreateFolder: Boolean, // Enable or disable folder creation in Feed Tab
val canCreateFeed: Boolean = true, val canCreateFeed: Boolean = true,
val canUpdateFolder: Boolean = true, val canUpdateFolder: Boolean = true,
val canUpdateFeed: Boolean = true, val canUpdateFeed: Boolean = true,
val canDeleteFeed: Boolean = true, val canDeleteFeed: Boolean = true,

View File

@ -4,13 +4,13 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.readrops.db.R import com.readrops.db.R
// TODO comment Feedly enum class AccountType(
enum class AccountType(@DrawableRes val iconRes: Int, @DrawableRes val iconRes: Int,
@StringRes val typeName: Int, @StringRes val nameRes: Int,
val accountConfig: AccountConfig?) { val config: AccountConfig
) {
LOCAL(R.mipmap.ic_launcher, R.string.local_account, AccountConfig.LOCAL), LOCAL(R.mipmap.ic_launcher, R.string.local_account, AccountConfig.LOCAL),
NEXTCLOUD_NEWS(R.drawable.ic_nextcloud_news, R.string.nextcloud_news, AccountConfig.NEXTCLOUD_NEWS), NEXTCLOUD_NEWS(R.drawable.ic_nextcloud_news, R.string.nextcloud_news, AccountConfig.NEXTCLOUD_NEWS),
FEEDLY(R.drawable.ic_feedly, R.string.feedly, null),
FRESHRSS(R.drawable.ic_freshrss, R.string.freshrss, AccountConfig.FRESHRSS), FRESHRSS(R.drawable.ic_freshrss, R.string.freshrss, AccountConfig.FRESHRSS),
FEVER(R.drawable.ic_fever, R.string.fever, AccountConfig.FEVER) FEVER(R.drawable.ic_fever, R.string.fever, AccountConfig.FEVER)
} }

View File

@ -1,7 +1,6 @@
package com.readrops.db.util package com.readrops.db.util
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.readrops.db.entities.account.AccountType
import java.time.LocalDateTime import java.time.LocalDateTime
class Converters { class Converters {
@ -15,15 +14,4 @@ class Converters {
fun fromLocalDateTime(localDateTime: LocalDateTime): Long { fun fromLocalDateTime(localDateTime: LocalDateTime): Long {
return localDateTime.toInstant(DateUtils.defaultOffset).toEpochMilli() return localDateTime.toInstant(DateUtils.defaultOffset).toEpochMilli()
} }
// TODO Use Room built-in enum converter, ordinal is not reliable
@TypeConverter
fun fromAccountTypeCode(ordinal: Int): AccountType {
return AccountType.entries[ordinal]
}
@TypeConverter
fun getAccountTypeCode(accountType: AccountType): Int {
return accountType.ordinal
}
} }