mirror of
https://codeberg.org/timelimit/timelimit-android.git
synced 2025-10-03 09:49:25 +02:00
Improve handling when own app lists at the server are defect
This commit is contained in:
parent
f3e83f9954
commit
56b7fe7e36
6 changed files with 260 additions and 188 deletions
|
@ -38,9 +38,6 @@ interface CryptContainerDao {
|
|||
@Query("SELECT * FROM crypt_container_metadata WHERE category_id = :categoryId AND device_id IS NULL AND type = :type")
|
||||
fun getCryptoMetadataSyncByCategoryId(categoryId: String, type: Int): CryptContainerMetadata?
|
||||
|
||||
@Query("DELETE FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type in (:types)")
|
||||
fun removeDeviceCryptoMetadata(deviceId: String, types: List<Int>)
|
||||
|
||||
@Query("SELECT * FROM crypt_container_metadata WHERE status = :processingStatus")
|
||||
fun getMetadataByProcessingStatus(processingStatus: CryptContainerMetadata.ProcessingStatus): List<CryptContainerMetadata>
|
||||
|
||||
|
|
|
@ -117,7 +117,10 @@ data class CryptContainerMetadata (
|
|||
nextCounter = 1,
|
||||
currentGenerationFirstTimestamp = System.currentTimeMillis(),
|
||||
currentGenerationKey = newKey
|
||||
)
|
||||
),
|
||||
type =
|
||||
if (currentGenerationKey == null) PrepareEncryptionResult.Type.NewContainer
|
||||
else PrepareEncryptionResult.Type.IncrementedGeneration
|
||||
)
|
||||
} else {
|
||||
PrepareEncryptionResult(
|
||||
|
@ -128,15 +131,23 @@ data class CryptContainerMetadata (
|
|||
),
|
||||
newMetadata = copy(
|
||||
nextCounter = nextCounter + 1
|
||||
)
|
||||
),
|
||||
type = PrepareEncryptionResult.Type.IncrementedCounter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PrepareEncryptionResult (
|
||||
val params: CryptContainer.EncryptParameters,
|
||||
val newMetadata: CryptContainerMetadata
|
||||
)
|
||||
val newMetadata: CryptContainerMetadata,
|
||||
val type: Type
|
||||
) {
|
||||
enum class Type {
|
||||
NewContainer,
|
||||
IncrementedGeneration,
|
||||
IncrementedCounter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CryptContainerMetadataProcessingStatusConverter {
|
||||
|
|
20
app/src/main/java/io/timelimit/android/extensions/Wire.kt
Normal file
20
app/src/main/java/io/timelimit/android/extensions/Wire.kt
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
|
||||
*
|
||||
* 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 version 3 of the License.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package io.timelimit.android.extensions
|
||||
|
||||
import com.squareup.wire.Message
|
||||
|
||||
fun <M : Message<M, B>, B : Message.Builder<M, B>> Message<M, B>.encodedSize() = this.adapter.encodedSize(this as M)
|
|
@ -21,6 +21,7 @@ import io.timelimit.android.crypto.CryptContainer
|
|||
import io.timelimit.android.data.Database
|
||||
import io.timelimit.android.data.model.CryptContainerData
|
||||
import io.timelimit.android.data.model.CryptContainerMetadata
|
||||
import io.timelimit.android.extensions.encodedSize
|
||||
import io.timelimit.android.logic.ServerApiLevelInfo
|
||||
import io.timelimit.android.proto.build
|
||||
import io.timelimit.android.proto.encodeDeflated
|
||||
|
@ -43,55 +44,92 @@ object CryptoAppListSync {
|
|||
disableLegacySync: Boolean,
|
||||
serverApiLevelInfo: ServerApiLevelInfo
|
||||
) {
|
||||
fun dispatch(action: AppLogicAction) {
|
||||
val compressedDataSizeLimit =
|
||||
if (serverApiLevelInfo.hasLevelOrIsOffline(5)) 1024 * 512
|
||||
else 1024 * 256
|
||||
|
||||
fun dispatchSync(action: AppLogicAction) {
|
||||
if (deviceState.isConnectedMode) {
|
||||
ApplyActionUtil.addAppLogicActionToDatabaseSync(action, database)
|
||||
}
|
||||
}
|
||||
|
||||
val compressedDataSizeLimit =
|
||||
if (serverApiLevelInfo.hasLevelOrIsOffline(5)) 1024 * 512
|
||||
else 1024 * 256
|
||||
fun <T> prepareEncryption(encrypted: InstalledAppsUtil.Encrypted<T>?, type: Int, forceNewGeneration: Boolean = false) = if (encrypted == null) {
|
||||
val key = CryptContainer.EncryptParameters.generate()
|
||||
|
||||
val savedCrypt = Threads.database.executeAndWait {
|
||||
InstalledAppsUtil.getEncryptedInstalledAppsFromDatabaseSync(database, deviceState.id)
|
||||
val metadata = CryptContainerMetadata.buildFor(
|
||||
deviceId = deviceState.id,
|
||||
categoryId = null,
|
||||
type = type,
|
||||
params = key
|
||||
)
|
||||
|
||||
CryptContainerMetadata.PrepareEncryptionResult(key, metadata, CryptContainerMetadata.PrepareEncryptionResult.Type.NewContainer)
|
||||
} else {
|
||||
if (encrypted.meta.type != type) throw IllegalStateException()
|
||||
|
||||
encrypted.meta.copy(
|
||||
status = CryptContainerMetadata.ProcessingStatus.Finished
|
||||
).prepareEncryption(forceNewGeneration)
|
||||
}
|
||||
|
||||
if (savedCrypt == null) {
|
||||
val baseKey = CryptContainer.EncryptParameters.generate()
|
||||
val diffKey = CryptContainer.EncryptParameters.generate()
|
||||
fun throwIfTooLarge(data: ByteArray) {
|
||||
data.size.let {
|
||||
if (it > compressedDataSizeLimit) throw TooLargeException(it)
|
||||
}
|
||||
}
|
||||
|
||||
val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseKey)
|
||||
val diffEncrypted = CryptContainer.encrypt(SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(), diffKey)
|
||||
val savedCrypt = InstalledAppsUtil.getEncryptedInstalledAppsFromDatabase(database, deviceState.id)
|
||||
|
||||
if (baseEncrypted.size > compressedDataSizeLimit) throw TooLargeException(baseEncrypted.size)
|
||||
val baseCryptConfig = prepareEncryption(
|
||||
encrypted = savedCrypt.base,
|
||||
type = CryptContainerMetadata.TYPE_APP_LIST_BASE
|
||||
)
|
||||
|
||||
val diffCryptConfig = prepareEncryption(
|
||||
encrypted = savedCrypt.diff,
|
||||
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF,
|
||||
forceNewGeneration = baseCryptConfig.type != CryptContainerMetadata.PrepareEncryptionResult.Type.IncrementedCounter || savedCrypt.base?.decrypted == null
|
||||
)
|
||||
|
||||
val diffCrypto: InstalledAppsDifferenceProto? = if (savedCrypt.base?.decrypted == null) null
|
||||
else AppsDifferenceUtil.calculateAppsDifference(savedCrypt.base.decrypted.data, installed)
|
||||
|
||||
if (
|
||||
savedCrypt.base?.decrypted == null ||
|
||||
savedCrypt.diff?.decrypted == null ||
|
||||
diffCrypto != savedCrypt.diff.decrypted.data
|
||||
) {
|
||||
if (
|
||||
savedCrypt.base?.decrypted == null ||
|
||||
savedCrypt.diff?.decrypted == null ||
|
||||
diffCrypto == null ||
|
||||
baseCryptConfig.type != CryptContainerMetadata.PrepareEncryptionResult.Type.IncrementedCounter ||
|
||||
diffCrypto.encodedSize() >= savedCrypt.base.decrypted.data.encodedSize() / 10
|
||||
) {
|
||||
val (baseEncrypted, diffEncrypted) = Threads.crypto.executeAndWait {
|
||||
val baseEncrypted = CryptContainer.encrypt(
|
||||
installed.encodeDeflated(),
|
||||
baseCryptConfig.params
|
||||
)
|
||||
|
||||
val diffEncrypted = CryptContainer.encrypt(
|
||||
SavedAppsDifferenceProto.build(
|
||||
baseEncrypted,
|
||||
InstalledAppsDifferenceProto()
|
||||
).encodeDeflated(),
|
||||
diffCryptConfig.params
|
||||
)
|
||||
|
||||
Pair(baseEncrypted, diffEncrypted)
|
||||
}
|
||||
|
||||
throwIfTooLarge(baseEncrypted)
|
||||
throwIfTooLarge(diffEncrypted)
|
||||
|
||||
Threads.database.executeAndWait {
|
||||
database.cryptContainer().removeDeviceCryptoMetadata(
|
||||
deviceId = deviceState.id,
|
||||
types = listOf(
|
||||
CryptContainerMetadata.TYPE_APP_LIST_BASE,
|
||||
CryptContainerMetadata.TYPE_APP_LIST_DIFF
|
||||
)
|
||||
)
|
||||
|
||||
val baseId = database.cryptContainer().insertMetadata(
|
||||
CryptContainerMetadata.buildFor(
|
||||
deviceId = deviceState.id,
|
||||
categoryId = null,
|
||||
type = CryptContainerMetadata.TYPE_APP_LIST_BASE,
|
||||
params = baseKey
|
||||
)
|
||||
)
|
||||
|
||||
val diffId = database.cryptContainer().insertMetadata(
|
||||
CryptContainerMetadata.buildFor(
|
||||
deviceId = deviceState.id,
|
||||
categoryId = null,
|
||||
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF,
|
||||
params = diffKey
|
||||
)
|
||||
)
|
||||
if (savedCrypt.base == null) {
|
||||
val baseId = database.cryptContainer().insertMetadata(baseCryptConfig.newMetadata)
|
||||
|
||||
database.cryptContainer().insertData(
|
||||
CryptContainerData(
|
||||
|
@ -99,6 +137,17 @@ object CryptoAppListSync {
|
|||
encryptedData = baseEncrypted
|
||||
)
|
||||
)
|
||||
} else {
|
||||
database.cryptContainer().updateMetadata(baseCryptConfig.newMetadata)
|
||||
|
||||
database.cryptContainer().updateData(CryptContainerData(
|
||||
cryptContainerId = savedCrypt.base.meta.cryptContainerId,
|
||||
encryptedData = baseEncrypted
|
||||
))
|
||||
}
|
||||
|
||||
if (savedCrypt.diff == null) {
|
||||
val diffId = database.cryptContainer().insertMetadata(diffCryptConfig.newMetadata)
|
||||
|
||||
database.cryptContainer().insertData(
|
||||
CryptContainerData(
|
||||
|
@ -106,57 +155,16 @@ object CryptoAppListSync {
|
|||
encryptedData = diffEncrypted
|
||||
)
|
||||
)
|
||||
|
||||
dispatch(UpdateInstalledAppsAction(
|
||||
base = baseEncrypted,
|
||||
diff = diffEncrypted,
|
||||
wipe = disableLegacySync
|
||||
))
|
||||
}
|
||||
|
||||
syncUtil.requestImportantSync()
|
||||
} else {
|
||||
val diffCrypto = AppsDifferenceUtil.calculateAppsDifference(savedCrypt.base, installed)
|
||||
database.cryptContainer().updateMetadata(diffCryptConfig.newMetadata)
|
||||
|
||||
if (diffCrypto != savedCrypt.diff) {
|
||||
val baseSize = savedCrypt.base.adapter.encodedSize(savedCrypt.base)
|
||||
val diffSize = diffCrypto.adapter.encodedSize(diffCrypto)
|
||||
val needsNewBySize = diffSize >= baseSize / 10
|
||||
val baseNeedsNewGeneration = savedCrypt.baseMeta.needsNewGeneration()
|
||||
val diffNeedsNewGeneration = savedCrypt.diffMeta.needsNewGeneration() or baseNeedsNewGeneration
|
||||
|
||||
val diffCryptParams = savedCrypt.diffMeta.prepareEncryption(diffNeedsNewGeneration)
|
||||
|
||||
if (needsNewBySize or baseNeedsNewGeneration) {
|
||||
val baseCryptParams = savedCrypt.baseMeta.prepareEncryption(baseNeedsNewGeneration)
|
||||
|
||||
val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseCryptParams.params)
|
||||
|
||||
val diffEncrypted = CryptContainer.encrypt(
|
||||
SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(),
|
||||
diffCryptParams.params
|
||||
)
|
||||
|
||||
if (baseEncrypted.size > compressedDataSizeLimit) throw TooLargeException(baseEncrypted.size)
|
||||
|
||||
Threads.database.executeAndWait {
|
||||
database.cryptContainer().updateMetadata(listOf(
|
||||
baseCryptParams.newMetadata,
|
||||
diffCryptParams.newMetadata
|
||||
))
|
||||
|
||||
database.cryptContainer().updateData(listOf(
|
||||
CryptContainerData(
|
||||
cryptContainerId = savedCrypt.baseMeta.cryptContainerId,
|
||||
encryptedData = baseEncrypted
|
||||
),
|
||||
CryptContainerData(
|
||||
cryptContainerId = savedCrypt.diffMeta.cryptContainerId,
|
||||
database.cryptContainer().updateData(CryptContainerData(
|
||||
cryptContainerId = savedCrypt.diff.meta.cryptContainerId,
|
||||
encryptedData = diffEncrypted
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
dispatch(UpdateInstalledAppsAction(
|
||||
dispatchSync(UpdateInstalledAppsAction(
|
||||
base = baseEncrypted,
|
||||
diff = diffEncrypted,
|
||||
wipe = disableLegacySync
|
||||
|
@ -165,24 +173,29 @@ object CryptoAppListSync {
|
|||
|
||||
syncUtil.requestImportantSync()
|
||||
} else {
|
||||
val diffEncrypted = CryptContainer.encrypt(
|
||||
SavedAppsDifferenceProto.build(savedCrypt.baseHeader, diffCrypto).encodeDeflated(),
|
||||
diffCryptParams.params
|
||||
val diffEncrypted = Threads.crypto.executeAndWait {
|
||||
CryptContainer.encrypt(
|
||||
SavedAppsDifferenceProto.build(
|
||||
savedCrypt.base.decrypted.header,
|
||||
diffCrypto
|
||||
).encodeDeflated(),
|
||||
diffCryptConfig.params
|
||||
)
|
||||
}
|
||||
|
||||
if (diffEncrypted.size > compressedDataSizeLimit) throw TooLargeException(diffEncrypted.size)
|
||||
throwIfTooLarge(diffEncrypted)
|
||||
|
||||
Threads.database.executeAndWait {
|
||||
database.cryptContainer().updateMetadata(diffCryptParams.newMetadata)
|
||||
database.cryptContainer().updateMetadata(diffCryptConfig.newMetadata)
|
||||
|
||||
database.cryptContainer().updateData(
|
||||
CryptContainerData(
|
||||
cryptContainerId = savedCrypt.diffMeta.cryptContainerId,
|
||||
cryptContainerId = savedCrypt.diff.meta.cryptContainerId,
|
||||
encryptedData = diffEncrypted
|
||||
)
|
||||
)
|
||||
|
||||
dispatch(UpdateInstalledAppsAction(
|
||||
dispatchSync(UpdateInstalledAppsAction(
|
||||
base = null,
|
||||
diff = diffEncrypted,
|
||||
wipe = disableLegacySync
|
||||
|
@ -194,4 +207,3 @@ object CryptoAppListSync {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -45,11 +45,13 @@ object InstalledAppsUtil {
|
|||
)
|
||||
}
|
||||
|
||||
fun getEncryptedInstalledAppsFromDatabaseSync(database: Database, deviceId: String): DecryptedInstalledApps? {
|
||||
suspend fun getEncryptedInstalledAppsFromDatabase(database: Database, deviceId: String): EncryptedInstalledApps {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "getEncryptedInstalledAppsFromDatabaseSync()")
|
||||
Log.d(LOG_TAG, "getEncryptedInstalledAppsFromDatabase()")
|
||||
}
|
||||
|
||||
val (baseValue, diffValue) = Threads.database.executeAndWait {
|
||||
database.runInTransaction {
|
||||
val baseValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId(
|
||||
deviceId = deviceId,
|
||||
type = CryptContainerMetadata.TYPE_APP_LIST_BASE
|
||||
|
@ -60,22 +62,17 @@ object InstalledAppsUtil {
|
|||
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF
|
||||
)
|
||||
|
||||
Pair(baseValue, diffValue)
|
||||
}
|
||||
}
|
||||
|
||||
return Threads.crypto.executeAndWait {
|
||||
val baseDecrypted = try {
|
||||
if (
|
||||
baseValue == null ||
|
||||
baseValue.metadata.currentGenerationKey == null ||
|
||||
baseValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished ||
|
||||
diffValue == null ||
|
||||
diffValue.metadata.currentGenerationKey == null ||
|
||||
diffValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished
|
||||
baseValue != null &&
|
||||
baseValue.metadata.currentGenerationKey != null &&
|
||||
baseValue.metadata.status == CryptContainerMetadata.ProcessingStatus.Finished
|
||||
) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "incomplete data")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
val (baseHeader, baseDecrypted, diffDecrypted) = try {
|
||||
val baseHeader = CryptContainer.Header.read(baseValue.encryptedData)
|
||||
|
||||
val baseDecrypted = CryptContainer.decrypt(
|
||||
|
@ -83,52 +80,79 @@ object InstalledAppsUtil {
|
|||
baseValue.encryptedData
|
||||
)
|
||||
|
||||
val baseData = InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted)
|
||||
|
||||
Decrypted(
|
||||
data = baseData,
|
||||
header = baseHeader
|
||||
)
|
||||
} else null
|
||||
} catch (ex: CryptException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "could not decrypt previous base data", ex)
|
||||
}
|
||||
|
||||
null
|
||||
} catch (ex: IOException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "could not decode previous base data", ex)
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
val diffDecrypted = try {
|
||||
if (
|
||||
diffValue != null &&
|
||||
diffValue.metadata.currentGenerationKey != null &&
|
||||
diffValue.metadata.status == CryptContainerMetadata.ProcessingStatus.Finished
|
||||
) {
|
||||
val diffHeader = CryptContainer.Header.read(diffValue.encryptedData)
|
||||
|
||||
val diffDecrypted = CryptContainer.decrypt(
|
||||
diffValue.metadata.currentGenerationKey,
|
||||
diffValue.encryptedData
|
||||
)
|
||||
|
||||
Triple(baseHeader, baseDecrypted, diffDecrypted)
|
||||
} catch (ex: CryptException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "could not decrypt previous data", ex)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
val (base, diff) = try {
|
||||
val base = InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted)
|
||||
|
||||
val diff = SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted).apps
|
||||
val diffData =
|
||||
SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted).apps
|
||||
?: InstalledAppsDifferenceProto()
|
||||
|
||||
Pair(base, diff)
|
||||
Decrypted(
|
||||
data = diffData,
|
||||
header = diffHeader
|
||||
)
|
||||
} else null
|
||||
} catch (ex: CryptException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "could not decrypt previous diff data", ex)
|
||||
}
|
||||
|
||||
null
|
||||
} catch (ex: IOException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LOG_TAG, "could not decode data", ex)
|
||||
Log.d(LOG_TAG, "could not decode previous diff data", ex)
|
||||
}
|
||||
|
||||
return null
|
||||
null
|
||||
}
|
||||
|
||||
return DecryptedInstalledApps(
|
||||
base = base,
|
||||
baseMeta = baseValue.metadata,
|
||||
baseHeader = baseHeader,
|
||||
diff = diff,
|
||||
diffMeta = diffValue.metadata
|
||||
EncryptedInstalledApps(
|
||||
base = baseValue?.let { Encrypted(it.metadata, baseDecrypted) },
|
||||
diff = diffValue?.let { Encrypted(it.metadata, diffDecrypted) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class DecryptedInstalledApps(
|
||||
val base: InstalledAppsProto,
|
||||
val baseHeader: CryptContainer.Header,
|
||||
val baseMeta: CryptContainerMetadata,
|
||||
val diff: InstalledAppsDifferenceProto,
|
||||
val diffMeta: CryptContainerMetadata
|
||||
data class EncryptedInstalledApps(
|
||||
val base: Encrypted<InstalledAppsProto>?,
|
||||
val diff: Encrypted<InstalledAppsDifferenceProto>?
|
||||
)
|
||||
|
||||
data class Encrypted<T>(val meta: CryptContainerMetadata, val decrypted: Decrypted<T>?)
|
||||
|
||||
data class Decrypted<T>(val data: T, val header: CryptContainer.Header)
|
||||
|
||||
suspend fun getInstalledAppsFromOs(appLogic: AppLogic, deviceState: DeviceState): InstalledAppsProto {
|
||||
val apps = kotlin.run {
|
||||
val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait {
|
||||
|
|
|
@ -59,7 +59,15 @@ object CryptDataHandler {
|
|||
} else oldItem.copy(serverVersion = data.version)
|
||||
|
||||
if (deviceId == database.config().getOwnDeviceIdSync()) {
|
||||
database.cryptContainer().updateMetadata(currentItem)
|
||||
val updatedItem = if (isUnmodified)
|
||||
currentItem
|
||||
else
|
||||
// this tells the sync logic to not use the existing encrypted data and start a new generation
|
||||
// there is no need to actually save the data from the server because it is not used at all
|
||||
// the DecryptProcessor ignores data for the device itself
|
||||
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.Unprocessed)
|
||||
|
||||
database.cryptContainer().updateMetadata(updatedItem)
|
||||
|
||||
return Result(didCreateKeyRequests = false)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue