Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified resources/images/font.ttf
Binary file not shown.
12 changes: 11 additions & 1 deletion src/common/api/common/utils/ErrorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ import { ExportError } from "../error/ExportError"
import { KeyVerificationMismatchError } from "../error/KeyVerificationMismatchError"
import { ServerModelsUnavailableError } from "../error/ServerModelsUnavailableError"

function isErrorObjectEmpty(obj: Record<string, unknown>): boolean {
return Object.keys(obj).length === 0
}

/**
* Checks if the given instance (Entity or ParsedInstance) has an error in the _errors property which is usually written
* if decryption fails for some reason in InstanceMapper.
Expand All @@ -67,7 +71,13 @@ import { ServerModelsUnavailableError } from "../error/ServerModelsUnavailableEr
*/
export function hasError<K>(instance: Entity | ParsedInstance, key?: K): boolean {
const downCastedInstance = downcast(instance)
return !instance || (!!downCastedInstance._errors && (!key || !!downCastedInstance._errors.key))
if (!instance) {
return true
} else {
const hasNonEmptyErrorObject = !!downCastedInstance._errors && !isErrorObjectEmpty(downCastedInstance._errors)

return hasNonEmptyErrorObject && (!key || !!downCastedInstance._errors.key)
}
}

//If importing fails it is a good idea to bundle the error into common-min which can be achieved by annotating the module with "<at>bundleInto:common-min"
Expand Down
5 changes: 3 additions & 2 deletions src/common/api/worker/offline/OfflineStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,12 +590,13 @@ export class OfflineStorage implements CacheStorage {
table: string,
): Promise<Array<StorableInstance>> {
const storables = await Promise.all(
instances.map(async (instance): Promise<StorableInstance> => {
instances.map(async (instance): Promise<Nullable<StorableInstance>> => {
const { listId, elementId } = expandId(AttributeModel.getAttribute<IdTuple | Id>(instance, "_id", typeModel))
if (hasError(instance)) {
console.warn(
`Trying to put parsed instance with _errors to offline storage. Type: ${typeModel.app}/${typeModel.name}, Id: ["${listId}", "${elementId}"]`,
)
return null
}
const ownerGroup = AttributeModel.getAttribute<Id>(instance, "_ownerGroup", typeModel)
const serializedInstance = await this.serialize(instance)
Expand All @@ -612,7 +613,7 @@ export class OfflineStorage implements CacheStorage {
}
}),
)
return storables
return storables.filter((storable) => storable !== null)
}

private async fetchRowIds(
Expand Down
47 changes: 27 additions & 20 deletions src/common/api/worker/offline/PatchMerger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,31 +70,38 @@ export class PatchMerger {
public async patchAndStoreInstance(entityUpdate: EntityUpdateData): Promise<Nullable<ServerModelParsedInstance>> {
const { typeRef, instanceListId, instanceId, patches, instance } = entityUpdate

const patchAppliedInstance = await this.getPatchedInstanceParsed(typeRef, instanceListId, instanceId, assertNotNull(patches))
if (patchAppliedInstance == null) {
return null
}

if (entityUpdate !== null && instance !== null) {
const isPatchAndAppliedInstanceMatch = await this.isInstanceOnUpdateIsSameAsPatched(entityUpdate, patchAppliedInstance)
if (!isPatchAndAppliedInstanceMatch) {
if (!hasError(instance)) {
// we do not want to put the instance in the offline storage if there are _errors (when decrypting)
await this.cacheStorage.put(typeRef, instance)
}
// There are concurrency issues with the File and Mail types due to bucketKey and UpdateSessionKeyService
if (!isSameTypeRef(FileTypeRef, entityUpdate.typeRef) && !isSameTypeRef(MailTypeRef, entityUpdate.typeRef)) {
throw new ProgrammingError(
"instance with id [" + instanceListId + ", " + instanceId + `] has not been successfully patched. Type: ${getTypeString(typeRef)}`,
)
try {
const patchAppliedInstance = await this.getPatchedInstanceParsed(typeRef, instanceListId, instanceId, assertNotNull(patches))
if (patchAppliedInstance == null) {
return null
}
if (entityUpdate !== null && instance !== null) {
const isPatchAndAppliedInstanceMatch = await this.isInstanceOnUpdateIsSameAsPatched(entityUpdate, patchAppliedInstance)
if (!isPatchAndAppliedInstanceMatch) {
if (!hasError(instance)) {
// we do not want to put the instance in the offline storage if there are _errors (when decrypting)
await this.cacheStorage.put(typeRef, instance)
}
// There are concurrency issues with the File and Mail types due to bucketKey and UpdateSessionKeyService
if (!isSameTypeRef(FileTypeRef, entityUpdate.typeRef) && !isSameTypeRef(MailTypeRef, entityUpdate.typeRef)) {
throw new ProgrammingError(
"instance with id [" + instanceListId + ", " + instanceId + `] has not been successfully patched. Type: ${getTypeString(typeRef)}`,
)
}
} else {
await this.cacheStorage.put(typeRef, patchAppliedInstance)
}
} else {
await this.cacheStorage.put(typeRef, patchAppliedInstance)
}
} else {
await this.cacheStorage.put(typeRef, patchAppliedInstance)
return patchAppliedInstance
} catch (e) {
if (e instanceof PatchOperationError) {
// returning null leads to reloading from the server, this fixes the broken entity in the offline storage with _errors
return null
}
throw e
}
return patchAppliedInstance
}

private async applySinglePatch(parsedInstance: ServerModelParsedInstance, typeModel: ServerTypeModel, patch: Patch) {
Expand Down
29 changes: 21 additions & 8 deletions src/common/api/worker/offline/migrations/offline-v7.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { OfflineMigration } from "../OfflineStorageMigrator.js"
import { AppType } from "../../../../misc/ClientConstants"
import { NOTHING_INDEXED_TIMESTAMP } from "../../../common/TutanotaConstants"
import { sql } from "../Sql"
import { assertNotNull } from "@tutao/tutanota-utils"
import { untagSqlValue } from "../SqlValue"

/**
* Empties search index since it could have been inconsistent.
Expand All @@ -13,30 +15,41 @@ export const offline7: OfflineMigration = {
async migrate(_: OfflineStorage, sqlCipherFacade: SqlCipherFacade): Promise<void> {
if (APP_TYPE === AppType.Mail || APP_TYPE === AppType.Integrated) {
console.log("Droping search indices...")
{

// Copied from OfflineStorage
const tableExists = async (tableName: string): Promise<boolean> => {
// Read the schema for the table https://sqlite.org/schematab.html
const { query, params } = sql`SELECT COUNT(*) as metadata_exists
FROM sqlite_schema
WHERE name = ${tableName}`
const result = assertNotNull(await sqlCipherFacade.get(query, params))
return untagSqlValue(result["metadata_exists"]) === 1
}

if (await tableExists("content_mail_index")) {
const { query, params } = sql`DELETE
FROM content_mail_index`
await sqlCipherFacade.run(query, params)
}
{
if (await tableExists("mail_index")) {
const { query, params } = sql`DELETE
FROM mail_index`
await sqlCipherFacade.run(query, params)
}
{
if (await tableExists("search_metadata")) {
const { query, params } = sql`DELETE
FROM search_metadata
WHERE key = 'contactsIndexed'`
FROM search_metadata
WHERE key = 'contactsIndexed'`
await sqlCipherFacade.run(query, params)
}
{
if (await tableExists("contact_index")) {
const { query, params } = sql`DELETE
FROM contact_index`
await sqlCipherFacade.run(query, params)
}
{
if (await tableExists("search_group_data")) {
const { query, params } = sql`UPDATE search_group_data
SET indexedTimestamp = ${NOTHING_INDEXED_TIMESTAMP}`
SET indexedTimestamp = ${NOTHING_INDEXED_TIMESTAMP}`
await sqlCipherFacade.run(query, params)
}
}
Expand Down
21 changes: 13 additions & 8 deletions src/common/api/worker/rest/DefaultEntityRestCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ export class DefaultEntityRestCache implements EntityRestCache {

if (cachedEntity == null) {
const parsedInstance = await this.entityRestClient.loadParsedInstance(typeRef, id, opts)
if (cachingBehavior.writesToCache) {
if (cachingBehavior.writesToCache && !hasError(parsedInstance)) {
await this.storage.put(typeRef, parsedInstance)
}

Expand Down Expand Up @@ -628,38 +628,43 @@ export class DefaultEntityRestCache implements EntityRestCache {
wasReverseRequest: boolean,
receivedEntities: ServerModelParsedInstance[],
) {
// Filter out parsed instances after the first instances with _errors (i.e. decryption errors),
// because we should NEVER store instances in the storage that failed to be decrypted!
const elementsToAdd = wasReverseRequest ? receivedEntities.reverse() : receivedEntities
const firstElementToAddIndexWithError = elementsToAdd.findIndex((elementToAdd) => hasError(elementToAdd))
const elementsToAddWithoutErrors = firstElementToAddIndexWithError !== -1 ? elementsToAdd.slice(0, firstElementToAddIndexWithError) : elementsToAdd
const elementsToAddWithoutErrorsCount = elementsToAddWithoutErrors.length
await this.storage.putMultiple(typeRef, elementsToAddWithoutErrors)

const typeModel = await this.typeModelResolver.resolveServerTypeReference(typeRef)
const isCustomId = isCustomIdType(await this.typeModelResolver.resolveClientTypeReference(typeRef))
let elementsToAdd = receivedEntities
if (wasReverseRequest) {
// Ensure that elements are cached in ascending (not reverse) order
elementsToAdd = receivedEntities.reverse()
if (receivedEntities.length < countRequested) {
if (elementsToAddWithoutErrorsCount === receivedEntities.length && receivedEntities.length < countRequested) {
console.log("finished loading, setting min id")
await this.storage.setLowerRangeForList(typeRef, listId, isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID)
} else {
// After reversing the list the first element in the list is the lower range limit
await this.storage.setLowerRangeForList(
typeRef,
listId,
elementIdPart(AttributeModel.getAttribute(getFirstOrThrow(receivedEntities), "_id", typeModel)),
elementIdPart(AttributeModel.getAttribute(getFirstOrThrow(elementsToAddWithoutErrors), "_id", typeModel)),
)
}
} else {
// Last element in the list is the upper range limit
if (receivedEntities.length < countRequested) {
if (elementsToAddWithoutErrorsCount === receivedEntities.length && receivedEntities.length < countRequested) {
// all elements have been loaded, so the upper range must be set to MAX_ID
console.log("finished loading, setting max id")
await this.storage.setUpperRangeForList(typeRef, listId, isCustomId ? CUSTOM_MAX_ID : GENERATED_MAX_ID)
} else {
await this.storage.setUpperRangeForList(
typeRef,
listId,
elementIdPart(AttributeModel.getAttribute(lastThrow(receivedEntities), "_id", typeModel)),
elementIdPart(AttributeModel.getAttribute(lastThrow(elementsToAddWithoutErrors), "_id", typeModel)),
)
}
}
await this.storage.putMultiple(typeRef, elementsToAdd)
}

/**
Expand Down
10 changes: 5 additions & 5 deletions src/common/api/worker/rest/EphemeralCacheStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export class EphemeralCacheStorage implements CacheStorage {
console.warn(
`Trying to put parsed instance with _errors to ephemeral cache. Type: ${typeModel.app}/${typeModel.name}, Id: ["${listId}", "${elementId}"]`,
)
return
}
elementId = ensureBase64Ext(typeModel, elementId)

Expand Down Expand Up @@ -287,15 +288,14 @@ export class EphemeralCacheStorage implements CacheStorage {
// if the element already exists in the cache, overwrite it
// add new element to existing list if necessary
cache.elements.set(elementId, entity)
const typeModel = await this.typeModelResolver.resolveServerTypeReference(typeRef)
if (await this.isElementIdInCacheRange(typeRef, listId, customIdToBase64Url(typeModel, elementId))) {
this.insertIntoRange(cache.allRange, elementId)
}
// always put the item into allRange(backing array only used by ephemeralCache), even if it has not updated
// the range yet. It is a better option to have the item and range not updated yet than the opposite
this.insertIntoAllRange(cache.allRange, elementId)
}
}

/** precondition: elementId is converted to base64ext if necessary */
private insertIntoRange(allRange: Array<Id>, elementId: Id) {
private insertIntoAllRange(allRange: Array<Id>, elementId: Id) {
for (let i = 0; i < allRange.length; i++) {
const rangeElement = allRange[i]
if (firstBiggerThanSecond(rangeElement, elementId)) {
Expand Down
6 changes: 6 additions & 0 deletions src/common/gui/base/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const enum ButtonColor {
Elevated = "elevated",
DrawerNav = "drawernav",
Fab = "fab",
Dialog = "dialog",
}

export function getColors(buttonColors: ButtonColor | null | undefined): {
Expand Down Expand Up @@ -51,6 +52,11 @@ export function getColors(buttonColors: ButtonColor | null | undefined): {
border: getElevatedBackground(),
}

case ButtonColor.Dialog:
return {
button: theme.content_button,
border: theme.content_border,
}
case ButtonColor.Content:
default:
return {
Expand Down
5 changes: 2 additions & 3 deletions src/common/gui/base/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import { getElevatedBackground } from "../theme"
import { px, size } from "../size"
import { HabReminderImage } from "./icons/Icons"
import { windowFacade } from "../../misc/WindowFacade"
import type { ButtonAttrs } from "./Button.js"
import { Button, ButtonType } from "./Button.js"
import { Button, ButtonAttrs, ButtonColor, ButtonType } from "./Button.js"
import type { DialogHeaderBarAttrs } from "./DialogHeaderBar"
import { DialogHeaderBar } from "./DialogHeaderBar"
import { TextField, TextFieldType } from "./TextField.js"
Expand Down Expand Up @@ -516,7 +515,7 @@ export class Dialog implements ModalComponent {
? null
: m(
".flex-center.dialog-buttons",
buttons.map((a) => m(Button, a)),
buttons.map((a) => m(Button, { colors: ButtonColor.Dialog, ...a })),
),
],
})
Expand Down
Loading
Loading