diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52b2d82c5..af9c18302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,3 +148,57 @@ jobs: - name: assemble ${{ matrix.color }}${{ matrix.store }}${{ matrix.type }} run: ./gradlew assemble${{ matrix.color }}${{ matrix.store }}${{ matrix.type }} + + # Connected tests are per-store-variant, debug builds only. + connected: + strategy: + matrix: + color: ["Orange"] + store: ["Fdroid", "Github", "Google"] + type: ["Debug"] + api-level: [31] + target: [default] + name: Android Emulator Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: ./.github/actions/setup-build-env + with: + gradle-cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + arch: x86_64 + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + script: ./gradlew connected${{ matrix.color }}${{ matrix.store }}${{ matrix.type }}AndroidTest diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 7f4a52410..b929feb4e 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -88,7 +88,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull @@ -313,12 +312,13 @@ class NotificationsFragment : // remove it. is NotificationActionSuccess.AcceptFollowRequest, is NotificationActionSuccess.RejectFollowRequest, - -> refreshAdapterAndScrollToVisibleId() + -> adapter.refresh() } } when (uiSuccess) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId() + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation, + -> adapter.refresh() is UiSuccess.LoadNewest -> { // Scroll to the top when prepending completes. @@ -379,25 +379,6 @@ class NotificationsFragment : } } - /** - * Refreshes the adapter, waits for the first page to be updated, and scrolls the - * recyclerview to the first notification that was visible before the refresh. - * - * This ensures the user's position is not lost during adapter refreshes. - */ - private fun refreshAdapterAndScrollToVisibleId() { - getFirstVisibleNotification()?.id?.let { id -> - viewLifecycleOwner.lifecycleScope.launch { - adapter.onPagesUpdatedFlow.conflate().take(1).collect { - binding.recyclerView.scrollToPosition( - adapter.snapshot().items.indexOfFirst { it.id == id }, - ) - } - } - } - adapter.refresh() - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) menu.findItem(R.id.action_refresh)?.apply { @@ -446,7 +427,7 @@ class NotificationsFragment : } binding.swipeRefreshLayout.isRefreshing = false - refreshAdapterAndScrollToVisibleId() + adapter.refresh() clearNotificationsForAccount(requireContext(), pachliAccountId) } @@ -613,11 +594,11 @@ class NotificationsFragment : } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onBlock(block: Boolean, id: String, position: Int) { - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index 3996fe33d..6aef69d05 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -83,12 +83,12 @@ class CachedTimelineRepository @Inject constructor( factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) } val initialKey = remoteKeyDao.remoteKeyForKind(account.id, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)?.key - val row = initialKey?.let { timelineDao.getStatusRowNumber(account.id, it) } ?: 0 + val row = initialKey?.let { timelineDao.getStatusRowNumber(account.id, it) } Timber.d("initialKey: %s is row: %d", initialKey, row) return Pager( - initialKey = (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0), + initialKey = row?.let { (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0) }, config = PagingConfig( pageSize = PAGE_SIZE, enablePlaceholders = true, diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 932080c16..f411367b0 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -97,7 +97,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow @@ -352,17 +351,12 @@ class TimelineFragment : is StatusActionSuccess.Translate -> statusViewData.status } (indexedViewData.value as StatusViewData).status = status - - adapter.notifyItemChanged(indexedViewData.index) } // Refresh adapter on mutes and blocks when (it) { - is UiSuccess.Block, - is UiSuccess.Mute, - is UiSuccess.MuteConversation, - -> - refreshAdapterAndScrollToVisibleId() + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation, + -> adapter.refresh() is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) @@ -430,25 +424,6 @@ class TimelineFragment : } } - /** - * Refreshes the adapter, waits for the first page to be updated, and scrolls the - * recyclerview to the first status that was visible before the refresh. - * - * This ensures the user's position is not lost during adapter refreshes. - */ - private fun refreshAdapterAndScrollToVisibleId() { - getFirstVisibleStatus()?.id?.let { id -> - viewLifecycleOwner.lifecycleScope.launch { - adapter.onPagesUpdatedFlow.conflate().take(1).collect { - binding.recyclerView.scrollToPosition( - adapter.snapshot().items.indexOfFirst { it.id == id }, - ) - } - } - } - adapter.refresh() - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_timeline, menu) @@ -477,7 +452,6 @@ class TimelineFragment : R.id.action_load_newest -> { Timber.d("Reload because user chose load newest menu item") viewModel.accept(InfallibleUiAction.LoadNewest) - refreshContent() true } else -> false @@ -582,7 +556,7 @@ class TimelineFragment : } binding.swipeRefreshLayout.isRefreshing = false - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onReply(viewData: StatusViewData) { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 62e729f3f..ac10d4b19 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -29,6 +29,7 @@ import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Links import app.pachli.core.network.model.Status @@ -68,7 +69,7 @@ class CachedTimelineRemoteMediator( RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, )?.key - Timber.d("Loading from item: %s", statusId) + Timber.d("Refresh from item: %s", statusId) getInitialPage(statusId, state.config.pageSize) } @@ -78,7 +79,7 @@ class CachedTimelineRemoteMediator( RKE_TIMELINE_ID, RemoteKeyKind.PREV, ) ?: return@transactionProvider MediatorResult.Success(endOfPaginationReached = true) - Timber.d("Loading from remoteKey: %s", rke) + Timber.d("Prepend from remoteKey: %s", rke) mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize) } @@ -88,7 +89,7 @@ class CachedTimelineRemoteMediator( RKE_TIMELINE_ID, RemoteKeyKind.NEXT, ) ?: return@transactionProvider MediatorResult.Success(endOfPaginationReached = true) - Timber.d("Loading from remoteKey: %s", rke) + Timber.d("Append from remoteKey: %s", rke) mastodonApi.homeTimeline(maxId = rke.key, limit = state.config.pageSize) } } @@ -112,8 +113,10 @@ class CachedTimelineRemoteMediator( when (loadType) { LoadType.REFRESH -> { - remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID) - timelineDao.deleteAllStatusesForAccount(pachliAccountId) + timelineDao.deleteAllStatusesForAccountOnTimeline( + pachliAccountId, + TimelineStatusEntity.Kind.Home, + ) remoteKeyDao.upsert( RemoteKeyEntity( @@ -247,7 +250,8 @@ class CachedTimelineRemoteMediator( } /** - * Inserts `statuses` and the accounts referenced by those statuses in to the cache. + * Inserts `statuses` and the accounts referenced by those statuses in to the cache, + * then adds references to them in the Home timeline. */ private suspend fun insertStatuses(pachliAccountId: Long, statuses: List) { check(transactionProvider.inTransaction()) @@ -262,6 +266,15 @@ class CachedTimelineRemoteMediator( timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) statusDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) + timelineDao.upsertStatuses( + statuses.map { + TimelineStatusEntity( + kind = TimelineStatusEntity.Kind.Home, + pachliAccountId = pachliAccountId, + statusId = it.id, + ) + }, + ) } companion object { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 0d1735e00..2e4267e61 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -42,6 +42,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -74,9 +75,10 @@ class CachedTimelineViewModel @Inject constructor( flow { emit(repository.getRefreshKey(it.data!!.id)) } } - override var statuses = accountFlow.flatMapLatest { - getStatuses(it.data!!) - }.cachedIn(viewModelScope) + override var statuses = accountFlow + .distinctUntilChangedBy { it.data!!.id } + .flatMapLatest { getStatuses(it.data!!) } + .cachedIn(viewModelScope) /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ private suspend fun getStatuses( diff --git a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt index 78b2f7831..2d5341a0b 100644 --- a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt @@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor( * Clear the home timeline cache. */ suspend fun clearHomeTimelineCache(accountId: Long) { - timelineDao.deleteAllStatusesForAccount(accountId) + timelineDao.deleteAllStatusesForAccountOnTimeline(accountId) } /** diff --git a/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt b/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt index 0d275228b..cc491d9fc 100644 --- a/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt +++ b/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt @@ -45,7 +45,7 @@ class PruneCacheWorker @AssistedInject constructor( override suspend fun doWork(): Result { for (account in accountManager.accounts) { Timber.d("Pruning database using account ID: %d", account.id) - timelineDao.cleanup(account.id, MAX_STATUSES_IN_CACHE) + timelineDao.cleanup(account.id) } return Result.success() } @@ -53,7 +53,6 @@ class PruneCacheWorker @AssistedInject constructor( override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification) companion object { - private const val MAX_STATUSES_IN_CACHE = 1000 const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" } } diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index b96b20f71..b19a8b8d9 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -18,6 +18,7 @@ import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.json.BooleanIfNull import app.pachli.core.network.json.DefaultIfNull @@ -343,6 +344,15 @@ class CachedTimelineRemoteMediatorTest { } statusDao().insertStatus(statusWithAccount.status) } + timelineDao().upsertStatuses( + statuses.map { + TimelineStatusEntity( + pachliAccountId = it.status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = it.status.serverId, + ) + }, + ) } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt index 38cbb6445..dd1556aea 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import timber.log.Timber /** * Errors that can occur acting on a status. @@ -113,10 +114,10 @@ class NotificationsRepository @Inject constructor( // Room is row-keyed, not item-keyed. Find the user's REFRESH key, then find the // row of the notification with that ID, and use that as the Pager's initialKey. val initialKey = remoteKeyDao.remoteKeyForKind(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)?.key - val row = initialKey?.let { notificationDao.getNotificationRowNumber(pachliAccountId, it) } ?: 0 + val row = initialKey?.let { notificationDao.getNotificationRowNumber(pachliAccountId, it) } return Pager( - initialKey = (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0), + initialKey = row?.let { (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0) }, config = PagingConfig( pageSize = PAGE_SIZE, enablePlaceholders = true, @@ -145,6 +146,7 @@ class NotificationsRepository @Inject constructor( * refresh the newest notifications. */ suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async { + Timber.d("saveRefreshKey: $key") remoteKeyDao.upsert( RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key), ) diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 9740d131d..30e6487a6 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -26,7 +26,15 @@ android { namespace = "app.pachli.core.database" defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "app.pachli.core.database.HiltTestRunner" + } + + packaging { + resources.excludes.apply { + // Otherwise this error: + // "2 files found with path 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' from inputs:" + add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } } } @@ -48,4 +56,8 @@ dependencies { implementation(libs.semver)?.because("Converters has to convert Version") testImplementation(projects.core.testing) + + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.android) } diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/16.json b/core/database/schemas/app.pachli.core.database.AppDatabase/16.json new file mode 100644 index 000000000..32a11787a --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/16.json @@ -0,0 +1,2014 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "d1beca6e5800394037cfbdac9fb23916", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` INTEGER, `language` TEXT, `statusId` TEXT, FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DraftEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` TEXT NOT NULL DEFAULT '', `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `notificationAccountFilterNotFollowed` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterYounger30d` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterLimitedByServer` TEXT NOT NULL DEFAULT 'NONE')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileHeaderPictureUrl", + "columnName": "profileHeaderPictureUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSeveredRelationships", + "columnName": "notificationsSeveredRelationships", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "notificationAccountFilterNotFollowed", + "columnName": "notificationAccountFilterNotFollowed", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterYounger30d", + "columnName": "notificationAccountFilterYounger30d", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterLimitedByServer", + "columnName": "notificationAccountFilterLimitedByServer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `maxPostCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxPostCharacters", + "columnName": "maxPostCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTranslation", + "columnName": "enabledTranslation", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EmojisEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, `createdAt` INTEGER, `limited` INTEGER NOT NULL DEFAULT false, `note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limited", + "columnName": "limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineAccountEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineAccountEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusViewDataEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusViewDataEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MastodonListEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repliesPolicy", + "columnName": "repliesPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclusive", + "columnName": "exclusive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "listId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ServerEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverKind", + "columnName": "serverKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ContentFiltersEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilters", + "columnName": "contentFilters", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AnnouncementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "announcementId", + "columnName": "announcementId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FollowingAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `type` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `accountServerId` TEXT NOT NULL, `statusServerId` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`accountServerId`, `pachliAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountServerId", + "columnName": "accountServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusServerId", + "columnName": "statusServerId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountServerId", + "pachliAccountId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `reportId` TEXT NOT NULL, `actionTaken` INTEGER NOT NULL, `actionTakenAt` INTEGER, `category` TEXT NOT NULL, `comment` TEXT NOT NULL, `forwarded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusIds` TEXT, `ruleIds` TEXT, `target_serverId` TEXT NOT NULL, `target_timelineUserId` INTEGER NOT NULL, `target_localUsername` TEXT NOT NULL, `target_username` TEXT NOT NULL, `target_displayName` TEXT NOT NULL, `target_url` TEXT NOT NULL, `target_avatar` TEXT NOT NULL, `target_emojis` TEXT NOT NULL, `target_bot` INTEGER NOT NULL, `target_createdAt` INTEGER, `target_limited` INTEGER NOT NULL DEFAULT false, `target_note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionTaken", + "columnName": "actionTaken", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionTakenAt", + "columnName": "actionTakenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "forwarded", + "columnName": "forwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleIds", + "columnName": "ruleIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "targetAccount.serverId", + "columnName": "target_serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.timelineUserId", + "columnName": "target_timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.localUsername", + "columnName": "target_localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.username", + "columnName": "target_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.displayName", + "columnName": "target_displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.url", + "columnName": "target_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.avatar", + "columnName": "target_avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.emojis", + "columnName": "target_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.bot", + "columnName": "target_bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.createdAt", + "columnName": "target_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetAccount.limited", + "columnName": "target_limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "targetAccount.note", + "columnName": "target_note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "NotificationViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `contentFilterAction` TEXT, `accountFilterDecision` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilterAction", + "columnName": "contentFilterAction", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountFilterDecision", + "columnName": "accountFilterDecision", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationRelationshipSeveranceEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `type` TEXT NOT NULL, `purged` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL, `followingCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`, `eventId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purged", + "columnName": "purged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followingCount", + "columnName": "followingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId", + "eventId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `pachliAccountId` INTEGER NOT NULL, `statusId` TEXT NOT NULL, PRIMARY KEY(`kind`, `pachliAccountId`, `statusId`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind", + "pachliAccountId", + "statusId" + ] + }, + "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, 'd1beca6e5800394037cfbdac9fb23916')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt b/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt new file mode 100644 index 000000000..d3ede5f04 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * Test runner that sets the [HiltTestApplication] as the as the + * test application in instrumented tests. + * + * See https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests + * and the `testInstrumentationRunner` setting in `build.gradle.kts` for details. + */ +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt similarity index 90% rename from core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt rename to core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index a29287df2..256f94322 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -23,6 +23,7 @@ import app.pachli.core.database.AppDatabase import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Card import app.pachli.core.network.model.Emoji @@ -105,6 +106,15 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + kind = TimelineStatusEntity.Kind.Home, + pachliAccountId = status.timelineUserId, + statusId = status.serverId, + ), + ), + ) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) @@ -135,10 +145,30 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + pachliAccountId = status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = status.serverId, + ), + ), + ) } - timelineDao.cleanup(accountId = 1, limit = 3) - timelineDao.cleanupAccounts(accountId = 1) + // Remove some statuses from the home timeline for account 1L. This makes + // them targets for the cleanup. + arrayOf("5", "3", "1").forEach { + timelineDao.delete( + TimelineStatusEntity( + pachliAccountId = 1L, + kind = TimelineStatusEntity.Kind.Home, + statusId = it, + ), + ) + } + + timelineDao.cleanup(accountId = 1) val wantAccount1StatusesAfterCleanup = listOf( makeStatus(statusId = 100), @@ -150,7 +180,7 @@ class TimelineDaoTest { makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), ) - val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) val gotAccount1StatusesAfterCleanup = (timelineDao.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data val gotAccount2StatusesAfterCleanup = (timelineDao.getStatuses(2).load(loadParams) as PagingSource.LoadResult.Page).data @@ -334,7 +364,7 @@ class TimelineDaoTest { } @Test - fun `preview card survives roundtrip`() = runTest { + fun previewCardSurvivesRoundtrip() = runTest { val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") for ((status, author, reblogger) in listOf(setOne)) { @@ -343,6 +373,15 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + pachliAccountId = status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = status.serverId, + ), + ), + ) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 15d9957b6..1490714e3 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -64,6 +64,7 @@ import app.pachli.core.database.model.ServerEntity import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.model.ContentFilterVersion import java.text.SimpleDateFormat @@ -93,8 +94,9 @@ import java.util.TimeZone NotificationReportEntity::class, NotificationViewDataEntity::class, NotificationRelationshipSeveranceEventEntity::class, + TimelineStatusEntity::class, ], - version = 15, + version = 16, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), @@ -110,6 +112,7 @@ import java.util.TimeZone // 12 -> 13 is a custom migration AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class), AutoMigration(from = 14, to = 15, spec = AppDatabase.MIGRATE_14_15::class), + AutoMigration(from = 15, to = 16), ], ) abstract class AppDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index f2801febd..3249441e8 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -20,6 +20,7 @@ import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import app.pachli.core.database.model.ConversationAccountEntity import app.pachli.core.database.model.DraftAttachment +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.model.AccountFilterDecision import app.pachli.core.model.ContentFilter import app.pachli.core.model.ServerOperation @@ -310,4 +311,10 @@ class Converters @Inject constructor( @TypeConverter fun jsonToAccountFilterDecision(s: String?) = s?.let { moshi.adapter().fromJson(it) } + + @TypeConverter + fun timelineKindToJson(kind: TimelineStatusEntity.Kind): String = moshi.adapter().toJson(kind) + + @TypeConverter + fun jsonToTimelineKind(s: String?) = s?.let { moshi.adapter().fromJson(s) } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 1642b2af8..353a87556 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -19,6 +19,7 @@ package app.pachli.core.database.dao import androidx.paging.PagingSource import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.MapColumn import androidx.room.OnConflictStrategy.Companion.REPLACE @@ -30,11 +31,21 @@ import app.pachli.core.database.Converters import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount @Dao @TypeConverters(Converters::class) abstract class TimelineDao { + @Upsert + abstract suspend fun upsertStatuses(entities: List) + + @Delete + abstract suspend fun delete(entity: TimelineStatusEntity) + + @Delete + abstract suspend fun delete(entities: List) + @Insert(onConflict = REPLACE) abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @@ -105,30 +116,39 @@ SELECT svd.contentShowing AS 'svd_contentShowing', svd.contentCollapsed AS 'svd_contentCollapsed', svd.translationState AS 'svd_translationState', - t.serverId AS 't_serverId', - t.timelineUserId AS 't_timelineUserId', - t.content AS 't_content', - t.spoilerText AS 't_spoilerText', - t.poll AS 't_poll', - t.attachments AS 't_attachments', - t.provider AS 't_provider' -FROM StatusEntity AS s + tr.serverId AS 't_serverId', + tr.timelineUserId AS 't_timelineUserId', + tr.content AS 't_content', + tr.spoilerText AS 't_spoilerText', + tr.poll AS 't_poll', + tr.attachments AS 't_attachments', + tr.provider AS 't_provider' +FROM TimelineStatusEntity AS t +LEFT JOIN + StatusEntity AS s + ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN StatusViewDataEntity AS svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) LEFT JOIN - TranslatedStatusEntity AS t - ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId)) -WHERE s.timelineUserId = :account + TranslatedStatusEntity AS tr + ON (s.timelineUserId = tr.timelineUserId AND (s.serverId = tr.serverId OR s.reblogServerId = tr.serverId)) +WHERE t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """, ) - abstract fun getStatuses(account: Long): PagingSource + abstract fun getStatuses( + account: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ): PagingSource /** - * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId]. + * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId] + * on [timelineKind]. + * + * Rows are ordered newest (0) to oldest. * * @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses] */ @@ -136,13 +156,21 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """ SELECT rownum FROM ( + WITH statuses (timelineUserId, serverId) AS ( + SELECT + s.timelineUserId, + s.serverId + FROM TimelineStatusEntity AS t + LEFT JOIN StatusEntity AS s ON (t.statusId = s.serverId) + WHERE t.kind = :timelineKind AND t.pachliAccountId = :pachliAccountId + ) SELECT t1.timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum - FROM StatusEntity AS t1 + FROM statuses AS t1 INNER JOIN - StatusEntity AS t2 + statuses AS t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId) @@ -153,7 +181,11 @@ FROM ( WHERE serverId = :statusId """, ) - abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int + abstract suspend fun getStatusRowNumber( + pachliAccountId: Long, + statusId: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ): Int @Query( """ @@ -240,6 +272,8 @@ WHERE AND s.authorServerId IS NOT NULL """, ) + // TODO: Probably doesn't need to use TimelineStatus. Does need a + // pachliAccountId abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? @Query( @@ -251,39 +285,44 @@ WHERE timelineUserId = :accountId AND (LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) """, ) + // TODO: Needs to use TimelineStatus, only used in developer tools abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int @Query( """ DELETE -FROM StatusEntity +FROM TimelineStatusEntity WHERE - timelineUserId = :pachliAccountId - AND (authorServerId = :userId OR reblogAccountId = :userId) + kind = :timelineKind + AND pachliAccountId = :pachliAccountId + AND statusId IN ( + SELECT serverId + FROM StatusEntity + WHERE + timelineUserId = :pachliAccountId + AND (authorServerId = :userId OR reblogAccountId = :userId) + ) """, ) - abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String) + abstract suspend fun removeAllByUser( + pachliAccountId: Long, + userId: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ) /** - * Removes all statuses from the cached **home** timeline. - * - * Statuses that are referenced by notifications are retained, to ensure - * they show up in the Notifications list. + * Removes all statuses from [timelineKind] for [accountId] */ @Query( """ DELETE -FROM StatusEntity +FROM TimelineStatusEntity WHERE - timelineUserId = :accountId - AND serverId NOT IN ( - SELECT statusServerId - FROM NotificationEntity - WHERE statusServerId IS NOT NULL - ) + pachliAccountId = :accountId + AND kind = :timelineKind """, ) - abstract suspend fun deleteAllStatusesForAccount(accountId: Long) + abstract suspend fun deleteAllStatusesForAccountOnTimeline(accountId: Long, timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home) @Query( """ @@ -304,62 +343,39 @@ WHERE timelineUserId = :accountId abstract suspend fun removeAllTranslatedStatus(accountId: Long) /** - * Cleans the StatusEntity and TimelineAccountEntity tables from old entries. + * Removes cached data that is not part of any timeline. + * * @param accountId id of the account for which to clean tables - * @param limit how many statuses to keep */ @Transaction - open suspend fun cleanup(accountId: Long, limit: Int) { - cleanupStatuses(accountId, limit) + open suspend fun cleanup(accountId: Long) { + cleanupStatuses(accountId) cleanupAccounts(accountId) - cleanupStatusViewData(accountId, limit) - cleanupTranslatedStatus(accountId, limit) + cleanupStatusViewData(accountId) + cleanupTranslatedStatus(accountId) } /** - * Deletes rows from [StatusEntity], keeping the newest [keep] - * statuses. + * Removes rows from [StatusEntity] that are not referenced elsewhere. * * @param accountId id of the account for which to clean statuses - * @param keep (1-based) how many statuses to keep */ @Query( """ DELETE FROM StatusEntity -WHERE timelineUserId = :accountId AND serverId IN ( - SELECT serverId - FROM ( - WITH statuses (serverId) AS ( - -- Statuses that are not associated with a notification. - -- Left join with notifications, filter to statuses where the - -- join returns a NULL notification ID (because the status has - -- no associated notification) - SELECT s.serverId - FROM StatusEntity AS s - LEFT JOIN - NotificationEntity AS n - ON (s.serverId = n.statusServerId AND s.timelineUserId = n.pachliAccountId) - WHERE n.statusServerId IS NULL AND s.timelineUserId = :accountId - ) - - -- Calculate the row number for each row, and exclude rows where - -- the row number < limit - SELECT - t1.serverId, - COUNT(t2.serverId) AS rownum - FROM statuses AS t1 - LEFT JOIN statuses AS t2 - ON - LENGTH(t1.serverId) < LENGTH(t2.serverId) - OR (LENGTH(t1.serverId) = LENGTH(t2.serverId) AND t1.serverId < t2.serverId) - GROUP BY t1.serverId - HAVING rownum > :keep - ) +WHERE timelineUserId = :accountId AND serverId NOT IN ( + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupStatuses(accountId: Long, keep: Int) + abstract suspend fun cleanupStatuses(accountId: Long) /** * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the StatusEntity table @@ -391,8 +407,8 @@ WHERE abstract suspend fun cleanupAccounts(accountId: Long) /** - * Cleans the StatusViewDataEntity table of old view data, keeping the most recent [limit] - * entries. + * Removes rows from StatusViewDataEntity that reference statuses are that not + * part of any timeline. */ @Query( """ @@ -401,19 +417,21 @@ FROM StatusViewDataEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT serverId - FROM StatusViewDataEntity - WHERE timelineUserId = :accountId - ORDER BY LENGTH(serverId) DESC, serverId DESC - LIMIT :limit + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupStatusViewData(accountId: Long, limit: Int) + abstract suspend fun cleanupStatusViewData(accountId: Long) /** - * Cleans the TranslatedStatusEntity table of old data, keeping the most recent [limit] - * entries. + * Removes rows from TranslatedStatusEntity that reference statuses that are not + * part of any timeline. */ @Query( """ @@ -422,15 +440,17 @@ FROM TranslatedStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT serverId - FROM TranslatedStatusEntity - WHERE timelineUserId = :accountId - ORDER BY LENGTH(serverId) DESC, serverId DESC - LIMIT :limit + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupTranslatedStatus(accountId: Long, limit: Int) + abstract suspend fun cleanupTranslatedStatus(accountId: Long) @Upsert abstract suspend fun upsertStatusViewData(svd: StatusViewDataEntity) @@ -460,27 +480,46 @@ WHERE @Query( """ -DELETE -FROM StatusEntity -WHERE timelineUserId = :accountId AND authorServerId IN ( - SELECT serverId - FROM TimelineAccountEntity - WHERE - username LIKE '%@' || :instanceDomain - AND timelineUserId = :accountId +WITH statuses (serverId) AS ( + -- IDs of statuses written by accounts from :instanceDomain + SELECT s.serverId + FROM StatusEntity AS s + LEFT JOIN + TimelineAccountEntity AS a + ON (s.timelineUserId = a.timelineUserId AND (s.authorServerId = a.serverId OR s.reblogAccountId = a.serverId)) + WHERE s.timelineUserId = :accountId AND a.username LIKE '%@' || :instanceDomain ) + +DELETE +FROM TimelineStatusEntity +WHERE + kind = :timelineKind + AND pachliAccountId = :accountId + AND statusId IN ( + SELECT serverId + FROM statuses + ) """, ) - abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + abstract suspend fun deleteAllFromInstance( + accountId: Long, + instanceDomain: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ) @Query( """ SELECT COUNT(*) -FROM StatusEntity -WHERE timelineUserId = :accountId +FROM TimelineStatusEntity +WHERE + kind = :timelineKind + AND pachliAccountId = :accountId """, ) - abstract suspend fun getStatusCount(accountId: Long): Int + abstract suspend fun getStatusCount( + accountId: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ): Int /** Developer tools: Find N most recent status IDs */ @Query( diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt new file mode 100644 index 000000000..f5a33076a --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.TypeConverters +import app.pachli.core.database.Converters +import com.squareup.moshi.JsonClass +import dev.zacsweers.moshix.sealed.annotations.DefaultNull +import dev.zacsweers.moshix.sealed.annotations.TypeLabel + +/** + * M:N association between a [TimelineStatusEntity.Kind] and the statuses + * that make up the timeline. + */ +@Entity( + primaryKeys = ["kind", "pachliAccountId", "statusId"], +) +@TypeConverters(Converters::class) +data class TimelineStatusEntity( + val kind: Kind, + val pachliAccountId: Long, + val statusId: String, +) { + /** + * Cacheable timeline kinds. + * + * Timelines that are not cacheable (e.g., search queries) do not belong + * here. + */ + // TODO: Eventually these have to be the timeline kind types for + // remotekeys + // TODO: See also core.model.Timeline + @DefaultNull + @JsonClass(generateAdapter = true, generator = "sealed:type") + sealed interface Kind { + @TypeLabel("home") + data object Home : Kind + + @TypeLabel("local") + data object Local : Kind + + @TypeLabel("federated") + data object Federated : Kind + + // data class RemoteLocal(val serverDomain: String): K ? + + @TypeLabel("hashtag") + @JsonClass(generateAdapter = true) + data class Hashtag(val hashtag: String) : Kind + + @TypeLabel("link") + @JsonClass(generateAdapter = true) + data class Link(val url: String) : Kind + + @TypeLabel("list") + @JsonClass(generateAdapter = true) + data class List(val listId: String) : Kind + + @TypeLabel("direct") + data object Direct : Kind + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef85eee04..cada24a68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-splashscreen = "1.2.0-alpha02" androidx-swiperefresh-layout = "1.1.0" androidx-testing = "2.2.0" androidx-test-core-ktx = "1.6.1" +androidx-test-rules = "1.6.1" androidx-transition = "1.5.1" androidx-viewpager2 = "1.1.0" androidx-webkit = "1.12.1" @@ -151,6 +152,7 @@ androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.re androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefresh-layout" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core-ktx" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules"} androidx-transition = { module = "androidx.transition:transition-ktx", version.ref = "androidx-transition" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }