Fix incorrectly incrementing IDs before sending to server. (#1026)

* Fix incorrectly incrementing IDs before sending to server.

* Add TimelineRepositoryTest, fix adding placeholder, fix String#dec()

* Add more TimelineRepository tests, fix bugs

* Add tests for adding statuses from DB.
This commit is contained in:
Ivan Kupalov 2019-02-05 20:06:00 +01:00 committed by Konrad Pozniak
parent 85610a8311
commit 63952813c8
11 changed files with 631 additions and 208 deletions

View file

@ -123,6 +123,7 @@ dependencies {
kapt "com.google.dagger:dagger-android-processor:$daggerVersion" kapt "com.google.dagger:dagger-android-processor:$daggerVersion"
testImplementation 'org.robolectric:robolectric:4.1' testImplementation 'org.robolectric:robolectric:4.1'
testImplementation 'org.mockito:mockito-inline:2.23.4' testImplementation 'org.mockito:mockito-inline:2.23.4'
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0"
androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', { androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', {
exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'com.android.support', module: 'support-annotations'
}) })

View file

@ -59,7 +59,11 @@ LIMIT :limit""")
} }
@Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null @Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null
AND timelineUserId = :acccount AND serverId > :sinceId AND serverId < :maxId""") AND timelineUserId = :acccount AND
(LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId)
AND
(LENGTH(serverId) > LENGTH(:sinceId) OR LENGTH(serverId) == LENGTH(:sinceId) AND serverId > :sinceId)
""")
abstract fun removeAllPlaceholdersBetween(acccount: Long, maxId: String, sinceId: String) abstract fun removeAllPlaceholdersBetween(acccount: Long, maxId: String, sinceId: String)
@Query("""UPDATE TimelineStatusEntity SET favourited = :favourited @Query("""UPDATE TimelineStatusEntity SET favourited = :favourited

View file

@ -29,6 +29,8 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.network.TimelineCasesImpl import com.keylesspalace.tusky.network.TimelineCasesImpl
import com.keylesspalace.tusky.util.HtmlConverter
import com.keylesspalace.tusky.util.HtmlConverterImpl
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import javax.inject.Singleton import javax.inject.Singleton
@ -77,4 +79,10 @@ class AppModule {
fun providesDatabase(app: TuskyApplication): AppDatabase { fun providesDatabase(app: TuskyApplication): AppDatabase {
return app.serviceLocator.get(AppDatabase::class.java) return app.serviceLocator.get(AppDatabase::class.java)
} }
@Provides
@Singleton
fun providesHtmlConverter(): HtmlConverter {
return HtmlConverterImpl()
}
} }

View file

@ -6,6 +6,7 @@ import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRepository import com.keylesspalace.tusky.repository.TimelineRepository
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
import com.keylesspalace.tusky.util.HtmlConverter
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -13,7 +14,9 @@ import dagger.Provides
class RepositoryModule { class RepositoryModule {
@Provides @Provides
fun providesTimelineRepository(db: AppDatabase, mastodonApi: MastodonApi, fun providesTimelineRepository(db: AppDatabase, mastodonApi: MastodonApi,
accountManager: AccountManager, gson: Gson): TimelineRepository { accountManager: AccountManager, gson: Gson,
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson) htmlConverter: HtmlConverter): TimelineRepository {
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson,
htmlConverter)
} }
} }

View file

@ -244,14 +244,14 @@ public class TimelineFragment extends SFragment implements
if (this.kind == Kind.HOME) { if (this.kind == Kind.HOME) {
this.tryCache(); this.tryCache();
} else { } else {
sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1);
} }
} }
private void tryCache() { private void tryCache() {
// Request timeline from disk to make it quick, then replace it with timeline from // Request timeline from disk to make it quick, then replace it with timeline from
// the server to update it // the server to update it
this.timelineRepo.getStatuses(null, null, LOAD_AT_ONCE, this.timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE,
TimelineRequestMode.DISK) TimelineRequestMode.DISK)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
@ -278,7 +278,7 @@ public class TimelineFragment extends SFragment implements
} else { } else {
topId = CollectionsKt.first(statuses, Either::isRight).asRight().getId(); topId = CollectionsKt.first(statuses, Either::isRight).asRight().getId();
} }
this.timelineRepo.getStatuses(topId, null, LOAD_AT_ONCE, this.timelineRepo.getStatuses(topId, null, null, LOAD_AT_ONCE,
TimelineRequestMode.NETWORK) TimelineRequestMode.NETWORK)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
@ -520,12 +520,22 @@ public class TimelineFragment extends SFragment implements
} }
private void loadAbove() { private void loadAbove() {
Either<Placeholder, Status> firstOrNull = String firstOrNull = null;
CollectionsKt.firstOrNull(this.statuses, Either::isRight); String secondOrNull = null;
for (int i = 0; i < this.statuses.size(); i++) {
Either<Placeholder, Status> status = this.statuses.get(i);
if (status.isRight()) {
firstOrNull = status.asRight().getId();
if (i + 1 < statuses.size() && statuses.get(i + 1).isRight()) {
secondOrNull = statuses.get(i + 1).asRight().getId();
}
break;
}
}
if (firstOrNull != null) { if (firstOrNull != null) {
this.sendFetchTimelineRequest(null, firstOrNull.asRight().getId(), FetchEnd.TOP, -1); this.sendFetchTimelineRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1);
} else { } else {
this.sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); this.sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1);
} }
} }
@ -631,11 +641,16 @@ public class TimelineFragment extends SFragment implements
if (statuses.size() >= position && position > 0) { if (statuses.size() >= position && position > 0) {
Status fromStatus = statuses.get(position - 1).asRightOrNull(); Status fromStatus = statuses.get(position - 1).asRightOrNull();
Status toStatus = statuses.get(position + 1).asRightOrNull(); Status toStatus = statuses.get(position + 1).asRightOrNull();
String maxMinusOne =
statuses.size() > position + 1 && statuses.get(position + 2).isRight()
? statuses.get(position + 1).asRight().getId()
: null;
if (fromStatus == null || toStatus == null) { if (fromStatus == null || toStatus == null) {
Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position"); Log.e(TAG, "Failed to load more at " + position + ", wrong placeholder position");
return; return;
} }
sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), FetchEnd.MIDDLE, position); sendFetchTimelineRequest(fromStatus.getId(), toStatus.getId(), maxMinusOne,
FetchEnd.MIDDLE, position);
Placeholder placeholder = statuses.get(position).asLeft(); Placeholder placeholder = statuses.get(position).asLeft();
StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.getId(), true); StatusViewData newViewData = new StatusViewData.Placeholder(placeholder.getId(), true);
@ -810,14 +825,14 @@ public class TimelineFragment extends SFragment implements
break; break;
} }
} }
sendFetchTimelineRequest(bottomId, null, FetchEnd.BOTTOM, -1); sendFetchTimelineRequest(bottomId, null, null, FetchEnd.BOTTOM, -1);
} }
private void fullyRefresh() { private void fullyRefresh() {
statuses.clear(); statuses.clear();
updateAdapter(); updateAdapter();
bottomLoading = true; bottomLoading = true;
sendFetchTimelineRequest(null, null, FetchEnd.BOTTOM, -1); sendFetchTimelineRequest(null, null, null, FetchEnd.BOTTOM, -1);
} }
private boolean jumpToTopAllowed() { private boolean jumpToTopAllowed() {
@ -861,7 +876,8 @@ public class TimelineFragment extends SFragment implements
} }
} }
private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId, private void sendFetchTimelineRequest(@Nullable String maxId, @Nullable String sinceId,
@Nullable String sinceIdMinusOne,
final FetchEnd fetchEnd, final int pos) { final FetchEnd fetchEnd, final int pos) {
if (kind == Kind.HOME) { if (kind == Kind.HOME) {
TimelineRequestMode mode; TimelineRequestMode mode;
@ -871,7 +887,7 @@ public class TimelineFragment extends SFragment implements
} else { } else {
mode = TimelineRequestMode.NETWORK; mode = TimelineRequestMode.NETWORK;
} }
timelineRepo.getStatuses(fromId, uptoId, LOAD_AT_ONCE, mode) timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe( .subscribe(
@ -895,7 +911,7 @@ public class TimelineFragment extends SFragment implements
} }
}; };
Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId); Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, maxId, sinceId);
callList.add(listCall); callList.add(listCall);
listCall.enqueue(callback); listCall.enqueue(callback);
} }

View file

@ -11,10 +11,7 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.HtmlUtils
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import java.io.IOException import java.io.IOException
@ -30,7 +27,7 @@ enum class TimelineRequestMode {
} }
interface TimelineRepository { interface TimelineRepository {
fun getStatuses(maxId: String?, sinceId: String?, limit: Int, fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>> requestMode: TimelineRequestMode): Single<out List<TimelineStatus>>
companion object { companion object {
@ -42,15 +39,17 @@ class TimelineRepositoryImpl(
private val timelineDao: TimelineDao, private val timelineDao: TimelineDao,
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val gson: Gson private val gson: Gson,
private val htmlConverter: HtmlConverter
) : TimelineRepository { ) : TimelineRepository {
init { init {
this.cleanup() this.cleanup()
} }
override fun getStatuses(maxId: String?, sinceId: String?, limit: Int, override fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?,
requestMode: TimelineRequestMode): Single<out List<TimelineStatus>> { limit: Int, requestMode: TimelineRequestMode
): Single<out List<TimelineStatus>> {
val acc = accountManager.activeAccount ?: throw IllegalStateException() val acc = accountManager.activeAccount ?: throw IllegalStateException()
val accountId = acc.id val accountId = acc.id
val instance = acc.domain val instance = acc.domain
@ -58,21 +57,19 @@ class TimelineRepositoryImpl(
return if (requestMode == DISK) { return if (requestMode == DISK) {
this.getStatusesFromDb(accountId, maxId, sinceId, limit) this.getStatusesFromDb(accountId, maxId, sinceId, limit)
} else { } else {
getStatusesFromNetwork(maxId, sinceId, limit, instance, accountId, requestMode) getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, instance, accountId,
requestMode)
} }
} }
private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, limit: Int, private fun getStatusesFromNetwork(maxId: String?, sinceId: String?,
instance: String, accountId: Long, sinceIdMinusOne: String?, limit: Int, instance: String,
requestMode: TimelineRequestMode accountId: Long, requestMode: TimelineRequestMode
): Single<out List<TimelineStatus>> { ): Single<out List<TimelineStatus>> {
val maxIdInc = maxId?.let(String::inc) return mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1)
val sinceIdDec = sinceId?.let(String::dec) .map { statuses ->
return mastodonApi.homeTimelineSingle(maxIdInc, sinceIdDec, limit + 2)
.doAfterSuccess { statuses ->
this.saveStatusesToDb(instance, accountId, statuses, maxId, sinceId) this.saveStatusesToDb(instance, accountId, statuses, maxId, sinceId)
} }
.map { statuses -> this.removePlaceholdersAndMap(statuses, maxId, sinceId) }
.flatMap { statuses -> .flatMap { statuses ->
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode)
} }
@ -85,22 +82,6 @@ class TimelineRepositoryImpl(
} }
} }
private fun removePlaceholdersAndMap(statuses: List<Status>, maxId: String?,
sinceId: String?
): List<Either.Right<Placeholder, Status>> {
val statusesCopy = statuses.toMutableList()
// Remove first and last statuses if they were used used just for overlap
if (maxId != null && statusesCopy.firstOrNull()?.id == maxId) {
statusesCopy.removeAt(0)
}
if (sinceId != null && statusesCopy.lastOrNull()?.id == sinceId) {
statusesCopy.removeAt(statusesCopy.size - 1)
}
return statusesCopy.map { s -> Either.Right<Placeholder, Status>(s) }
}
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>, private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>,
maxId: String?, sinceId: String?, limit: Int, maxId: String?, sinceId: String?, limit: Int,
requestMode: TimelineRequestMode requestMode: TimelineRequestMode
@ -109,8 +90,7 @@ class TimelineRepositoryImpl(
val newMaxID = if (statuses.isEmpty()) { val newMaxID = if (statuses.isEmpty()) {
maxId maxId
} else { } else {
// It's statuses from network. They're always Right statuses.last { it.isRight() }.asRight().id
statuses.last().asRight().id
} }
this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) this.getStatusesFromDb(accountId, newMaxID, sinceId, limit)
.map { fromDb -> .map { fromDb ->
@ -137,71 +117,64 @@ class TimelineRepositoryImpl(
} }
private fun saveStatusesToDb(instance: String, accountId: Long, statuses: List<Status>, private fun saveStatusesToDb(instance: String, accountId: Long, statuses: List<Status>,
maxId: String?, sinceId: String?) { maxId: String?, sinceId: String?
): List<Either<Placeholder, Status>> {
var placeholderToInsert: Placeholder? = null
// Look for overlap
val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) {
val indexOfSince = statuses.indexOfLast { it.id == sinceId }
if (indexOfSince == -1) {
// We didn't find the status which must be there. Add a placeholder
placeholderToInsert = Placeholder(sinceId.inc())
statuses.mapTo(mutableListOf(), Status::lift)
.apply {
add(Either.Left(placeholderToInsert))
}
} else {
// There was an overlap. Remove all overlapped statuses. No need for a placeholder.
statuses.mapTo(mutableListOf(), Status::lift)
.apply {
subList(indexOfSince, size).clear()
}
}
} else {
// Just a normal case.
statuses.map(Status::lift)
}
Single.fromCallable { Single.fromCallable {
val (prepend, append) = calculatePlaceholders(maxId, sinceId, statuses)
if (prepend != null) {
timelineDao.insertStatusIfNotThere(prepend.toEntity(accountId))
}
if (append != null) {
timelineDao.insertStatusIfNotThere(append.toEntity(accountId))
}
for (status in statuses) { for (status in statuses) {
timelineDao.insertInTransaction( timelineDao.insertInTransaction(
status.toEntity(accountId, instance), status.toEntity(accountId, instance, htmlConverter, gson),
status.account.toEntity(instance, accountId), status.account.toEntity(instance, accountId, gson),
status.reblog?.account?.toEntity(instance, accountId) status.reblog?.account?.toEntity(instance, accountId, gson)
) )
} }
placeholderToInsert?.let {
timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId))
}
// If we're loading in the bottom insert placeholder after every load
// (for requests on next launches) but not return it.
if (sinceId == null && statuses.isNotEmpty()) {
timelineDao.insertStatusIfNotThere(
Placeholder(statuses.last().id.dec()).toEntity(accountId))
}
// There may be placeholders which we thought could be from our TL but they are not // There may be placeholders which we thought could be from our TL but they are not
if (statuses.size > 2) { if (statuses.size > 2) {
timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id, timelineDao.removeAllPlaceholdersBetween(accountId, statuses.first().id,
statuses.last().id) statuses.last().id)
} else if (maxId != null && sinceId != null) { } else if (placeholderToInsert == null && maxId != null && sinceId != null) {
timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId)
} }
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} return resultStatuses
private fun calculatePlaceholders(maxId: String?, sinceId: String?,
statuses: List<Status>
): Pair<Placeholder?, Placeholder?> {
if (statuses.isEmpty()) return null to null
val firstId = statuses.first().id
val prepend = if (maxId != null) {
if (maxId > firstId) {
val decMax = maxId.dec()
if (decMax != firstId) {
Placeholder(decMax)
} else null
} else null
} else {
// Placeholders never overwrite real values so it's safe
Placeholder(firstId.inc())
}
val lastId = statuses.last().id
val append = if (sinceId != null) {
if (sinceId < lastId) {
val incSince = sinceId.inc()
if (incSince != lastId) {
Placeholder(incSince)
} else null
} else null
} else {
// Placeholders never overwrite real values so it's safe
Placeholder(lastId.dec())
}
return prepend to append
} }
private fun cleanup() { private fun cleanup() {
@ -215,42 +188,6 @@ class TimelineRepositoryImpl(
.subscribe() .subscribe()
} }
private fun Account.toEntity(instance: String, accountId: Long): TimelineAccountEntity {
return TimelineAccountEntity(
serverId = id,
timelineUserId = accountId,
instance = instance,
localUsername = localUsername,
username = username,
displayName = displayName,
url = url,
avatar = avatar,
emojis = gson.toJson(emojis)
)
}
private fun TimelineAccountEntity.toAccount(): Account {
return Account(
id = serverId,
localUsername = localUsername,
username = username,
displayName = displayName,
note = SpannedString(""),
url = url,
avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = false,
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type),
fields = null,
moved = null
)
}
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { private fun TimelineStatusWithAccount.toStatus(): TimelineStatus {
if (this.status.authorServerId == null) { if (this.status.authorServerId == null) {
return Either.Left(Placeholder(this.status.serverId)) return Either.Left(Placeholder(this.status.serverId))
@ -268,11 +205,11 @@ class TimelineRepositoryImpl(
Status( Status(
id = id, id = id,
url = status.url, url = status.url,
account = account.toAccount(), account = account.toAccount(gson),
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = HtmlUtils.fromHtml(status.content), content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -293,7 +230,7 @@ class TimelineRepositoryImpl(
Status( Status(
id = status.serverId, id = status.serverId,
url = null, // no url for reblogs url = null, // no url for reblogs
account = this.reblogAccount!!.toAccount(), account = this.reblogAccount!!.toAccount(gson),
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = reblog, reblog = reblog,
@ -316,11 +253,11 @@ class TimelineRepositoryImpl(
Status( Status(
id = status.serverId, id = status.serverId,
url = status.url, url = status.url,
account = account.toAccount(), account = account.toAccount(gson),
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = HtmlUtils.fromHtml(status.content), content = status.content?.let(htmlConverter::fromHtml) ?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -338,64 +275,103 @@ class TimelineRepositoryImpl(
} }
return Either.Right(status) return Either.Right(status)
} }
private fun Status.toEntity(timelineUserId: Long, instance: String): TimelineStatusEntity {
val actionable = actionableStatus
return TimelineStatusEntity(
serverId = this.id,
url = actionable.url!!,
instance = instance,
timelineUserId = timelineUserId,
authorServerId = actionable.account.id,
inReplyToId = actionable.inReplyToId,
inReplyToAccountId = actionable.inReplyToAccountId,
content = HtmlUtils.toHtml(actionable.content),
createdAt = actionable.createdAt.time,
emojis = actionable.emojis.let(gson::toJson),
reblogsCount = actionable.reblogsCount,
favouritesCount = actionable.favouritesCount,
reblogged = actionable.reblogged,
favourited = actionable.favourited,
sensitive = actionable.sensitive,
spoilerText = actionable.spoilerText,
visibility = actionable.visibility,
attachments = actionable.attachments.let(gson::toJson),
mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id }
)
}
private fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
return TimelineStatusEntity(
serverId = this.id,
url = null,
instance = null,
timelineUserId = timelineUserId,
authorServerId = null,
inReplyToId = null,
inReplyToAccountId = null,
content = null,
createdAt = 0L,
emojis = null,
reblogsCount = 0,
favouritesCount = 0,
reblogged = false,
favourited = false,
sensitive = false,
spoilerText = null,
visibility = null,
attachments = null,
mentions = null,
application = null,
reblogServerId = null,
reblogAccountId = null
)
}
companion object {
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
}
} }
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
fun Account.toEntity(instance: String, accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity(
serverId = id,
timelineUserId = accountId,
instance = instance,
localUsername = localUsername,
username = username,
displayName = displayName,
url = url,
avatar = avatar,
emojis = gson.toJson(emojis)
)
}
fun TimelineAccountEntity.toAccount(gson: Gson): Account {
return Account(
id = serverId,
localUsername = localUsername,
username = username,
displayName = displayName,
note = SpannedString(""),
url = url,
avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = false,
emojis = gson.fromJson(this.emojis, emojisListTypeToken.type),
fields = null,
moved = null
)
}
fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
return TimelineStatusEntity(
serverId = this.id,
url = null,
instance = null,
timelineUserId = timelineUserId,
authorServerId = null,
inReplyToId = null,
inReplyToAccountId = null,
content = null,
createdAt = 0L,
emojis = null,
reblogsCount = 0,
favouritesCount = 0,
reblogged = false,
favourited = false,
sensitive = false,
spoilerText = null,
visibility = null,
attachments = null,
mentions = null,
application = null,
reblogServerId = null,
reblogAccountId = null
)
}
fun Status.toEntity(timelineUserId: Long, instance: String,
htmlConverter: HtmlConverter,
gson: Gson): TimelineStatusEntity {
val actionable = actionableStatus
return TimelineStatusEntity(
serverId = this.id,
url = actionable.url!!,
instance = instance,
timelineUserId = timelineUserId,
authorServerId = actionable.account.id,
inReplyToId = actionable.inReplyToId,
inReplyToAccountId = actionable.inReplyToAccountId,
content = htmlConverter.toHtml(actionable.content),
createdAt = actionable.createdAt.time,
emojis = actionable.emojis.let(gson::toJson),
reblogsCount = actionable.reblogsCount,
favouritesCount = actionable.favouritesCount,
reblogged = actionable.reblogged,
favourited = actionable.favourited,
sensitive = actionable.sensitive,
spoilerText = actionable.spoilerText,
visibility = actionable.visibility,
attachments = actionable.attachments.let(gson::toJson),
mentions = actionable.mentions.let(gson::toJson),
application = actionable.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id }
)
}
fun Status.lift(): Either<Placeholder, Status> = Either.Right(this)

View file

@ -0,0 +1,22 @@
package com.keylesspalace.tusky.util
import android.text.Spanned
/**
* Abstracting away Android-specific things.
*/
interface HtmlConverter {
fun fromHtml(html: String): Spanned
fun toHtml(text: Spanned): String
}
internal class HtmlConverterImpl : HtmlConverter {
override fun fromHtml(html: String): Spanned {
return HtmlUtils.fromHtml(html)
}
override fun toHtml(text: Spanned): String {
return HtmlUtils.toHtml(text)
}
}

View file

@ -25,7 +25,7 @@ fun randomAlphanumericString(count: Int): String {
fun String.inc(): String { fun String.inc(): String {
// We assume that we will stay in the safe range for now // We assume that we will stay in the safe range for now
val builder = this.toCharArray() val builder = this.toCharArray()
builder.last().inc() builder[lastIndex] = builder[lastIndex].inc()
return String(builder) return String(builder)
} }
@ -34,24 +34,25 @@ fun String.inc(): String {
* "Decrement" string so that during sorting it's smaller than [this]. * "Decrement" string so that during sorting it's smaller than [this].
*/ */
fun String.dec(): String { fun String.dec(): String {
if (this.isEmpty()) return this
val builder = this.toCharArray() val builder = this.toCharArray()
var i = builder.lastIndex var i = builder.lastIndex
while (i > 0) { while (i > 0) {
if (builder[i] > '0') { if (builder[i] > '0') {
builder[i] = builder[i].dec() builder[i] = builder[i].dec()
break return String(builder)
} else { } else {
builder[i] = 'z' builder[i] = 'z'
} }
i-- i--
} }
// All characters were '0' return if (builder[0] > '1') {
if (i == 0 && this.isNotEmpty()) { builder[0] = builder[0].dec()
// Remove one character String(builder)
return String(builder.copyOfRange(1, builder.size)) } else {
String(builder.copyOfRange(1, builder.size))
} }
return String(builder)
} }
/** /**

View file

@ -0,0 +1,50 @@
package android.text
// Used for stubbing Android implementation without slow & buggy Robolectric things
@Suppress("unused")
class SpannableString(private val text: CharSequence) : Spannable {
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
throw NotImplementedError()
}
override fun <T : Any?> getSpans(start: Int, end: Int, type: Class<T>?): Array<T> {
throw NotImplementedError()
}
override fun removeSpan(what: Any?) {
throw NotImplementedError()
}
override fun toString(): String {
return "FakeSpannableString[text=$text]"
}
override val length: Int
get() = text.length
override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int {
throw NotImplementedError()
}
override fun getSpanEnd(tag: Any?): Int {
throw NotImplementedError()
}
override fun getSpanFlags(tag: Any?): Int {
throw NotImplementedError()
}
override fun get(index: Int): Char {
throw NotImplementedError()
}
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
throw NotImplementedError()
}
override fun getSpanStart(tag: Any?): Int {
throw NotImplementedError()
}
}

View file

@ -1,6 +1,7 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import com.keylesspalace.tusky.util.dec import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThan
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
@ -11,7 +12,8 @@ class StringUtilsTest {
val lessList = listOf( val lessList = listOf(
"abc" to "bcd", "abc" to "bcd",
"ab" to "abc", "ab" to "abc",
"cb" to "abc" "cb" to "abc",
"1" to "2"
) )
lessList.forEach { (l, r) -> assertTrue("$l < $r", l.isLessThan(r)) } lessList.forEach { (l, r) -> assertTrue("$l < $r", l.isLessThan(r)) }
val notLessList = lessList.map { (l, r) -> r to l } + listOf( val notLessList = lessList.map { (l, r) -> r to l } + listOf(
@ -20,6 +22,15 @@ class StringUtilsTest {
notLessList.forEach { (l, r) -> assertFalse("not $l < $r", l.isLessThan(r)) } notLessList.forEach { (l, r) -> assertFalse("not $l < $r", l.isLessThan(r)) }
} }
@Test
fun inc() {
listOf(
"122" to "123",
"12A" to "12B",
"1" to "2"
).forEach { (l, r) -> assertEquals("$l + 1 = $r", r, l.inc()) }
}
@Test @Test
fun dec() { fun dec() {
listOf( listOf(
@ -28,7 +39,8 @@ class StringUtilsTest {
"120" to "11z", "120" to "11z",
"100" to "zz", "100" to "zz",
"0" to "", "0" to "",
"" to "" "" to "",
"2" to "1"
).forEach { (l, r) -> assertEquals("$l - 1 = $r", r, l.dec()) } ).forEach { (l, r) -> assertEquals("$l - 1 = $r", r, l.dec()) }
} }
} }

View file

@ -0,0 +1,330 @@
package com.keylesspalace.tusky.fragment
import android.text.Spanned
import com.google.gson.Gson
import com.keylesspalace.tusky.SpanUtilsTest
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.TimelineDao
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.*
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.HtmlConverter
import com.nhaarman.mockitokotlin2.isNull
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import io.reactivex.Single
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import io.reactivex.schedulers.TestScheduler
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import java.util.*
import java.util.concurrent.TimeUnit
class TimelineRepositoryTest {
@Mock
lateinit var timelineDao: TimelineDao
@Mock
lateinit var mastodonApi: MastodonApi
@Mock
lateinit var accountManager: AccountManager
lateinit var gson: Gson
lateinit var subject: TimelineRepository
lateinit var testScheduler: TestScheduler
val limit = 30
val account = AccountEntity(
id = 2,
accessToken = "token",
domain = "domain.com",
isActive = true
)
val htmlConverter = object : HtmlConverter {
override fun fromHtml(html: String): Spanned {
return SpanUtilsTest.FakeSpannable(html)
}
override fun toHtml(text: Spanned): String {
return text.toString()
}
}
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
whenever(accountManager.activeAccount).thenReturn(account)
gson = Gson()
testScheduler = TestScheduler()
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson,
htmlConverter)
}
@Test
fun testNetworkUnbounded() {
val statuses = listOf(
makeStatus("3"),
makeStatus("2")
)
whenever(mastodonApi.homeTimelineSingle(isNull(), isNull(), anyInt()))
.thenReturn(Single.just(statuses))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
.blockingGet()
assertEquals(statuses.map(Status::lift), result)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
for (status in statuses) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, account.domain, htmlConverter, gson),
status.account.toEntity(account.domain, account.id, gson),
null
)
}
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingTopNoGap() {
val response = listOf(
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimelineSingle(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(response))
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
assertEquals(
response.subList(0, 2).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, account.domain, htmlConverter, gson),
status.account.toEntity(account.domain, account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingTopWithGap() {
val response = listOf(
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
whenever(mastodonApi.homeTimelineSingle(null, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(response))
val result = subject.getStatuses(null, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
val placeholder = Placeholder("3")
assertEquals(response.map(Status::lift) + Either.Left(placeholder), result)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, account.domain, htmlConverter, gson),
status.account.toEntity(account.domain, account.id, gson),
null
)
}
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingMiddleNoGap() {
// Example timelne:
// 5
// 4
// [gap]
// 2
// 1
val response = listOf(
makeStatus("5"),
makeStatus("4"),
makeStatus("3"),
makeStatus("2")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "3"
whenever(mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(response))
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
assertEquals(
response.subList(0, response.lastIndex).map(Status::lift),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, account.domain, htmlConverter, gson),
status.account.toEntity(account.domain, account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verifyNoMoreInteractions(timelineDao)
}
@Test
fun testNetworkLoadingMiddleWithGap() {
// Example timelne:
// 6
// 5
// [gap]
// 2
// 1
val response = listOf(
makeStatus("6"),
makeStatus("5"),
makeStatus("4")
)
val sinceId = "2"
val sinceIdMinusOne = "1"
val maxId = "4"
whenever(mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1))
.thenReturn(Single.just(response))
val result = subject.getStatuses(maxId, sinceId, sinceIdMinusOne, limit,
TimelineRequestMode.NETWORK)
.blockingGet()
val placeholder = Placeholder("3")
assertEquals(
response.map(Status::lift) + Either.Left(placeholder),
result
)
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
// We assume for now that overlapped one is inserted but it's not that important
for (status in response) {
verify(timelineDao).insertInTransaction(
status.toEntity(account.id, account.domain, htmlConverter, gson),
status.account.toEntity(account.domain, account.id, gson),
null
)
}
verify(timelineDao).removeAllPlaceholdersBetween(account.id, response.first().id,
response.last().id)
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
verifyNoMoreInteractions(timelineDao)
}
@Test
fun addingFromDb() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
val status = makeStatus("2")
val dbStatus = makeStatus("1")
val dbResult = TimelineStatusWithAccount()
dbResult.status = dbStatus.toEntity(account.id, account.domain, htmlConverter, gson)
dbResult.account = status.account.toEntity(account.domain, account.id, gson)
whenever(mastodonApi.homeTimelineSingle(any(), any(), any()))
.thenReturn(Single.just(listOf(status)))
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
.thenReturn(Single.just(listOf(dbResult)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
}
@Test
fun addingFromDbExhausted() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
val status = makeStatus("4")
val dbResult = TimelineStatusWithAccount()
dbResult.status = Placeholder("2").toEntity(account.id)
val dbResult2 = TimelineStatusWithAccount()
dbResult2.status = Placeholder("1").toEntity(account.id)
whenever(mastodonApi.homeTimelineSingle(any(), any(), any()))
.thenReturn(Single.just(listOf(status)))
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
.blockingGet()
assertEquals(listOf(status).map(Status::lift), result)
}
private fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
return Status(
id = id,
account = account,
content = SpanUtilsTest.FakeSpannable("hello$id"),
createdAt = Date(),
emojis = listOf(),
reblogsCount = 3,
favouritesCount = 5,
sensitive = false,
visibility = Status.Visibility.PUBLIC,
spoilerText = "",
reblogged = true,
favourited = false,
attachments = listOf(),
mentions = arrayOf(),
application = null,
inReplyToAccountId = null,
inReplyToId = null,
pinned = false,
reblog = null,
url = "http://example.com/statuses/$id"
)
}
private fun makeAccount(id: String): Account {
return Account(
id = id,
localUsername = "test$id",
username = "test$id@example.com",
displayName = "Example Account $id",
note = SpanUtilsTest.FakeSpannable("Note! $id"),
url = "https://example.com/@test$id",
avatar = "avatar$id",
header = "Header$id",
followersCount = 300,
followingCount = 400,
statusesCount = 1000,
bot = false,
emojis = listOf(),
fields = null,
source = null
)
}
}