Add App List Encryption

This commit is contained in:
Jonas Lochmann 2022-07-25 02:00:00 +02:00
parent dc662a78f8
commit 62c83f045a
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
61 changed files with 4995 additions and 389 deletions

View file

@ -19,6 +19,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-parcelize'
apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'com.squareup.wire'
android { android {
compileSdkVersion 33 compileSdkVersion 33
@ -151,6 +152,10 @@ android {
} }
} }
wire {
kotlin {}
}
dependencies { dependencies {
def nav_version = "2.5.0" def nav_version = "2.5.0"
def room_version = "2.4.2" def room_version = "2.4.2"
@ -211,4 +216,6 @@ dependencies {
implementation 'org.whispersystems:curve25519-java:0.5.0' implementation 'org.whispersystems:curve25519-java:0.5.0'
implementation 'com.google.zxing:core:3.3.3' implementation 'com.google.zxing:core:3.3.3'
api "com.squareup.wire:wire-runtime:4.4.0"
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
/*
* 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.crypto
import java.nio.ByteBuffer
import java.security.SecureRandom
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object CryptContainer {
const val KEY_SIZE = 16
private const val AUTH_TAG_BITS = 128
private const val AUTH_TAG_BYTES = AUTH_TAG_BITS / 8
data class EncryptParameters(val generation: Long, val counter: Long, val key: ByteArray) {
companion object {
fun generate() = EncryptParameters(
key = generateKey(),
generation = 0,
counter = 0
)
}
}
data class Header (
val generation: Long,
val counter: Long,
val iv: Int
) {
companion object {
const val SIZE = 8 + 8 + 4
fun read(input: ByteArray): Header {
validate(input)
val buffer = ByteBuffer.wrap(input)
val generation = buffer.getLong(0)
val counter = buffer.getLong(8)
val iv = buffer.getInt(16)
return Header(
generation = generation,
counter = counter,
iv = iv
)
}
}
fun write(output: ByteBuffer) {
output.putLong(0, generation)
output.putLong(8, counter)
output.putInt(16, iv)
}
}
fun validate(input: ByteArray) {
if (input.size < Header.SIZE + AUTH_TAG_BYTES) throw CryptException.InvalidContainer()
}
fun generateKey() = ByteArray(KEY_SIZE).also { SecureRandom().nextBytes(it) }
private fun buildIV(counter: Long, iv: Int): ByteArray {
val result = ByteArray(12)
ByteBuffer.wrap(result).also {
it.putInt(0, iv)
it.putLong(4, counter)
}
return result
}
private fun buildSecretKey(key: ByteArray): SecretKeySpec {
if (key.size != KEY_SIZE) throw CryptException.InvalidKey()
return SecretKeySpec(key, "AES")
}
private fun buildAAD(generation: Long): ByteArray = ByteArray(8).also { result ->
ByteBuffer.wrap(result).putLong(0, generation)
}
fun decrypt(key: ByteArray, input: ByteArray): ByteArray {
val header = Header.read(input)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").also {
it.init(Cipher.DECRYPT_MODE, buildSecretKey(key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(header.counter, header.iv)))
it.updateAAD(buildAAD(header.generation))
}
try {
return cipher.doFinal(input, Header.SIZE, input.size - Header.SIZE)
} catch (ex: AEADBadTagException) {
throw CryptException.WrongKey()
}
}
fun encrypt(input: ByteArray, params: EncryptParameters): ByteArray {
val iv = SecureRandom().nextInt()
val cipher = Cipher.getInstance("AES/GCM/NoPadding").also {
it.init(Cipher.ENCRYPT_MODE, buildSecretKey(params.key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(params.counter, iv)))
it.updateAAD(buildAAD(params.generation))
}
val result = ByteArray(Header.SIZE + input.size + AUTH_TAG_BYTES)
val buffer = ByteBuffer.wrap(result)
Header(
iv = iv,
counter = params.counter,
generation = params.generation
).write(buffer)
if (cipher.doFinal(input, 0, input.size, result, Header.SIZE) != input.size + AUTH_TAG_BYTES) {
throw IllegalStateException()
}
return result
}
}

View file

@ -53,6 +53,9 @@ object Curve25519 {
fun sign(privateKey: ByteArray, message: ByteArray): ByteArray = instance.calculateSignature(privateKey, message) fun sign(privateKey: ByteArray, message: ByteArray): ByteArray = instance.calculateSignature(privateKey, message)
fun validateSignature(publicKey: ByteArray, message: ByteArray, signature: ByteArray) = instance.verifySignature(publicKey, message, signature) fun validateSignature(publicKey: ByteArray, message: ByteArray, signature: ByteArray) = instance.verifySignature(publicKey, message, signature)
// 32 bytes
fun sharedSecret(publicKey: ByteArray, privateKey: ByteArray) = instance.calculateAgreement(publicKey, privateKey)
} }
fun Curve25519KeyPair.serialize(): ByteArray { fun Curve25519KeyPair.serialize(): ByteArray {

View file

@ -0,0 +1,23 @@
/*
* 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.crypto
sealed class CryptException: RuntimeException() {
class InvalidContainer: CryptException()
class InvalidKey: CryptException()
class WrongKey: CryptException()
}

View file

@ -41,6 +41,10 @@ interface Database {
fun categoryNetworkId(): CategoryNetworkIdDao fun categoryNetworkId(): CategoryNetworkIdDao
fun childTasks(): ChildTaskDao fun childTasks(): ChildTaskDao
fun timeWarning(): CategoryTimeWarningDao fun timeWarning(): CategoryTimeWarningDao
fun cryptContainer(): CryptContainerDao
fun cryptContainerKeyRequest(): CryptContainerKeyRequestDao
fun cryptContainerKeyResult(): CryptContainerKeyResultDao
fun deviceKey(): DeviceKeyDao
fun <T> runInTransaction(block: () -> T): T fun <T> runInTransaction(block: () -> T): T
fun <T> runInUnobservedTransaction(block: () -> T): T fun <T> runInUnobservedTransaction(block: () -> T): T

View file

@ -299,6 +299,25 @@ object DatabaseMigrations {
} }
} }
private val MIGRATE_TP_V43 = object: Migration(42, 43) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_metadata` (`crypt_container_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` TEXT, `category_id` TEXT, `type` INTEGER NOT NULL, `server_version` TEXT NOT NULL, `current_generation` INTEGER NOT NULL, `current_generation_first_timestamp` INTEGER NOT NULL, `next_counter` INTEGER NOT NULL, `current_generation_key` BLOB, `status` INTEGER NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_device_id` ON `crypt_container_metadata` (`device_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_category_id` ON `crypt_container_metadata` (`category_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_data` (`crypt_container_id` INTEGER NOT NULL, `encrypted_data` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_pending_key_request` (`crypt_container_id` INTEGER NOT NULL, `request_time_crypt_container_generation` INTEGER NOT NULL, `request_sequence_id` INTEGER NOT NULL, `request_key` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_crypt_container_pending_key_request_request_sequence_id` ON `crypt_container_pending_key_request` (`request_sequence_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_key_result` (`request_sequence_id` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `status` INTEGER NOT NULL, PRIMARY KEY(`request_sequence_id`, `device_id`), FOREIGN KEY(`request_sequence_id`) REFERENCES `crypt_container_pending_key_request`(`request_sequence_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_request_sequence_id` ON `crypt_container_key_result` (`request_sequence_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_device_id` ON `crypt_container_key_result` (`device_id`)")
database.execSQL("CREATE TABLE IF NOT EXISTS `device_public_key` (`device_id` TEXT NOT NULL, `public_key` BLOB NOT NULL, `next_sequence_number` INTEGER NOT NULL, PRIMARY KEY(`device_id`), FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
}
}
val ALL = arrayOf( val ALL = arrayOf(
MIGRATE_TO_V2, MIGRATE_TO_V2,
MIGRATE_TO_V3, MIGRATE_TO_V3,
@ -340,6 +359,7 @@ object DatabaseMigrations {
MIGRATE_TO_V39, MIGRATE_TO_V39,
MIGRATE_TO_V40, MIGRATE_TO_V40,
MIGRATE_TO_V41, MIGRATE_TO_V41,
MIGRATE_TO_V42 MIGRATE_TO_V42,
MIGRATE_TP_V43
) )
} }

View file

@ -52,8 +52,13 @@ import java.util.concurrent.TimeUnit
UserLimitLoginCategory::class, UserLimitLoginCategory::class,
CategoryNetworkId::class, CategoryNetworkId::class,
ChildTask::class, ChildTask::class,
CategoryTimeWarning::class CategoryTimeWarning::class,
], version = 42) CryptContainerMetadata::class,
CryptContainerData::class,
CryptContainerPendingKeyRequest::class,
CryptContainerKeyResult::class,
DevicePublicKey::class
], version = 43)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object { companion object {
private val lock = Object() private val lock = Object()

View file

@ -66,7 +66,7 @@ abstract class CategoryDao {
abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long) abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long)
@Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category") @Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category")
abstract fun getCategoriesWithVersionNumbers(): LiveData<List<CategoryWithVersionNumbers>> abstract fun getCategoriesWithVersionNumbersSybc(): List<CategoryWithVersionNumbers>
@Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId") @Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId")
abstract fun updateCategoryAssignedAppsVersion(categoryId: String, assignedAppsVersion: String) abstract fun updateCategoryAssignedAppsVersion(categoryId: String, assignedAppsVersion: String)

View file

@ -25,6 +25,8 @@ import io.timelimit.android.data.model.ConfigurationItem
import io.timelimit.android.data.model.ConfigurationItemType import io.timelimit.android.data.model.ConfigurationItemType
import io.timelimit.android.data.model.ConfigurationItemTypeConverter import io.timelimit.android.data.model.ConfigurationItemTypeConverter
import io.timelimit.android.data.model.ConfigurationItemTypeUtil import io.timelimit.android.data.model.ConfigurationItemTypeUtil
import io.timelimit.android.extensions.base64
import io.timelimit.android.extensions.parseBase64
import io.timelimit.android.extensions.toJsonReader import io.timelimit.android.extensions.toJsonReader
import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
@ -86,28 +88,16 @@ abstract class ConfigDao {
updateValueSync(ConfigurationItemType.OwnDeviceId, deviceId) updateValueSync(ConfigurationItemType.OwnDeviceId, deviceId)
} }
fun getDeviceListVersion(): LiveData<String> { fun getDeviceListVersionSync(): String {
return getValueOfKeyAsync(ConfigurationItemType.DeviceListVersion).map { return getValueOfKeySync(ConfigurationItemType.DeviceListVersion) ?: ""
if (it == null) {
""
} else {
it
}
}
} }
fun setDeviceListVersionSync(deviceListVersion: String) { fun setDeviceListVersionSync(deviceListVersion: String) {
updateValueSync(ConfigurationItemType.DeviceListVersion, deviceListVersion) updateValueSync(ConfigurationItemType.DeviceListVersion, deviceListVersion)
} }
fun getUserListVersion(): LiveData<String> { fun getUserListVersionSync(): String {
return getValueOfKeyAsync(ConfigurationItemType.UserListVersion).map { return getValueOfKeySync(ConfigurationItemType.UserListVersion) ?: ""
if (it == null) {
""
} else {
it
}
}
} }
fun setUserListVersionSync(userListVersion: String) { fun setUserListVersionSync(userListVersion: String) {
@ -354,4 +344,23 @@ abstract class ConfigDao {
(getConsentFlagsSync() and (flags.inv())).toString(16) (getConsentFlagsSync() and (flags.inv())).toString(16)
) )
} }
fun getSigningKeySync() = getValueOfKeySync(ConfigurationItemType.SigningKey)?.parseBase64()
fun getSigningKeyAsync() = getValueOfKeyAsync(ConfigurationItemType.SigningKey).map { v -> v?.let { Base64.decode(it, 0) } }
fun setSigningKeySync(value: ByteArray) = updateValueSync(ConfigurationItemType.SigningKey, value.base64())
private fun getNextSigningSequenceNumberSync(): Long = getValueOfKeySync(ConfigurationItemType.SignSequenceNumber)?.toLong() ?: 0L
private fun setNextSigningSequenceNumberSync(value: Long) = updateValueSync(ConfigurationItemType.SignSequenceNumber, value.toString())
fun getNextSigningSequenceNumberAndIncrementIt(): Long {
val current = getNextSigningSequenceNumberSync()
setNextSigningSequenceNumberSync(current + 1)
return current
}
fun getLastServerKeyRequestSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastServerKeyRequestSequence)?.toLong()
fun setLastServerKeyRequestSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastServerKeyRequestSequence, value.toString())
fun getLastServerKeyResponseSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastKeyResponseSequence)?.toLong()
fun setLastServerKeyResponseSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastKeyResponseSequence, value.toString())
} }

View file

@ -0,0 +1,78 @@
/*
* 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.data.dao
import androidx.room.*
import io.timelimit.android.data.model.CryptContainerData
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.data.model.CryptContainerMetadataProcessingStatusConverter
@Dao
@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class)
interface CryptContainerDao {
@Query("SELECT * FROM crypt_container_metadata WHERE crypt_container_id = :containerId")
fun getCryptoMetadataSyncByContainerId(containerId: Long): CryptContainerMetadata?
@Query("SELECT * FROM crypt_container_metadata JOIN crypt_container_data USING (crypt_container_id) WHERE category_id IS NULL AND device_id = :deviceId AND type = :type")
fun getCryptoFullDataSyncByDeviceId(deviceId: String, type: Int): MetadataAndContent?
@Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id IS NULL AND type = :type")
fun getCryptoMetadataSyncByType(type: Int): CryptContainerMetadata?
@Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type = :type")
fun getCryptoMetadataSyncByDeviceId(deviceId: String, type: Int): CryptContainerMetadata?
@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>
@Insert
fun insertMetadata(container: CryptContainerMetadata): Long
@Update
fun updateMetadata(container: List<CryptContainerMetadata>)
@Update
fun updateMetadata(container: CryptContainerMetadata)
@Insert
fun insertData(data: CryptContainerData)
@Update
fun updateData(data: List<CryptContainerData>)
@Update
fun updateData(data: CryptContainerData)
@Query("SELECT * FROM crypt_container_data WHERE crypt_container_id = :containerId")
fun getData(containerId: Long): CryptContainerData?
@Query("UPDATE crypt_container_metadata SET server_version = ''")
fun deleteAllServerVersionNumbers()
@Entity
data class MetadataAndContent(
@Embedded
val metadata: CryptContainerMetadata,
@ColumnInfo(name = "encrypted_data")
val encryptedData: ByteArray
)
}

View file

@ -0,0 +1,40 @@
/*
* 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.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import io.timelimit.android.data.model.CryptContainerPendingKeyRequest
@Dao
interface CryptContainerKeyRequestDao {
@Query("SELECT * FROM crypt_container_pending_key_request WHERE crypt_container_id = :cryptContainerId")
fun byCryptContainerId(cryptContainerId: Long): CryptContainerPendingKeyRequest?
@Query("SELECT * FROM crypt_container_pending_key_request WHERE request_sequence_id = :requestId")
fun byRequestId(requestId: Long): CryptContainerPendingKeyRequest?
@Insert
fun insert(item: CryptContainerPendingKeyRequest)
@Delete
fun delete(item: CryptContainerPendingKeyRequest)
@Query("DELETE FROM crypt_container_pending_key_request")
fun deleteAll()
}

View file

@ -0,0 +1,30 @@
/*
* 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.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import io.timelimit.android.data.model.CryptContainerKeyResult
@Dao
interface CryptContainerKeyResultDao {
@Query("SELECT COUNT(*) FROM crypt_container_key_result WHERE request_sequence_id = :requestSequenceNumber AND device_id = :deviceId")
fun countResultItems(requestSequenceNumber: Long, deviceId: String): Long
@Insert
fun insert(item: CryptContainerKeyResult)
}

View file

@ -55,7 +55,7 @@ abstract class DeviceDao {
abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String) abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String)
@Query("SELECT id, apps_version FROM device") @Query("SELECT id, apps_version FROM device")
abstract fun getInstalledAppsVersions(): LiveData<List<DeviceWithAppVersion>> abstract fun getInstalledAppsVersionsSync(): List<DeviceWithAppVersion>
@Query("DELETE FROM device WHERE id IN (:deviceIds)") @Query("DELETE FROM device WHERE id IN (:deviceIds)")
abstract fun removeDevicesById(deviceIds: List<String>) abstract fun removeDevicesById(deviceIds: List<String>)
@ -92,6 +92,14 @@ abstract class DeviceDao {
@Query("SELECT COUNT(*) FROM device JOIN user ON (device.current_user_id = user.id) WHERE user.type = \"child\"") @Query("SELECT COUNT(*) FROM device JOIN user ON (device.current_user_id = user.id) WHERE user.type = \"child\"")
abstract fun countDevicesWithChildUser(): LiveData<Long> abstract fun countDevicesWithChildUser(): LiveData<Long>
fun getDeviceDetailDataSync() = getDeviceDetailDataSyncInternal(
CryptContainerMetadata.TYPE_APP_LIST_BASE,
CryptContainerMetadata.TYPE_APP_LIST_DIFF
)
@Query("SELECT d.id AS device_id, c1.server_version AS app_base_version, c2.server_version AS app_diff_version FROM device d LEFT JOIN crypt_container_metadata c1 ON (c1.device_id = d.id AND c1.type = :baseType) LEFT JOIN crypt_container_metadata c2 ON (c2.device_id = d.id AND c2.type = :diffType)")
protected abstract fun getDeviceDetailDataSyncInternal(baseType: Int, diffType: Int): List<DeviceDetailDataBase>
} }
data class DeviceWithAppVersion( data class DeviceWithAppVersion(
@ -100,3 +108,12 @@ data class DeviceWithAppVersion(
@ColumnInfo(name = "apps_version") @ColumnInfo(name = "apps_version")
val installedAppsVersions: String val installedAppsVersions: String
) )
data class DeviceDetailDataBase(
@ColumnInfo(name = "device_id")
val deviceId: String,
@ColumnInfo(name = "app_base_version")
val appBaseVersion: String?,
@ColumnInfo(name = "app_diff_version")
val appDiffVersion: String?
)

View file

@ -0,0 +1,38 @@
/*
* 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.data.dao
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import io.timelimit.android.data.model.DevicePublicKey
@Dao
interface DeviceKeyDao {
@Insert
fun insert(key: DevicePublicKey)
@Update
fun update(key: DevicePublicKey)
@Query("SELECT * FROM device_public_key WHERE device_id = :deviceId")
fun getSync(deviceId: String): DevicePublicKey?
@Query("SELECT * FROM device_public_key WHERE device_id = :deviceId")
fun getLive(deviceId: String): LiveData<DevicePublicKey?>
}

View file

@ -74,8 +74,6 @@ data class ConfigurationItem(
} }
} }
// TODO: validate config item values
enum class ConfigurationItemType { enum class ConfigurationItemType {
OwnDeviceId, OwnDeviceId,
UserListVersion, UserListVersion,
@ -102,6 +100,10 @@ enum class ConfigurationItemType {
ServerApiLevel, ServerApiLevel,
AnnoyManualUnblockCounter, AnnoyManualUnblockCounter,
ConsentFlags, ConsentFlags,
SigningKey,
SignSequenceNumber,
LastServerKeyRequestSequence,
LastKeyResponseSequence
} }
object ConfigurationItemTypeUtil { object ConfigurationItemTypeUtil {
@ -130,6 +132,10 @@ object ConfigurationItemTypeUtil {
private const val SERVER_API_LEVEL = 24 private const val SERVER_API_LEVEL = 24
private const val ANNOY_MANUAL_UNBLOCK_COUNTER = 25 private const val ANNOY_MANUAL_UNBLOCK_COUNTER = 25
private const val CONSENT_FLAGS = 26 private const val CONSENT_FLAGS = 26
private const val SIGNING_KEY = 27
private const val SIGN_SEQUENCE_NUMBER = 28
private const val LAST_SERVER_KEY_REQUEST_SEQUENCE = 29
private const val LAST_SERVER_KEY_RESPONSE_SEQUENCE = 30
val TYPES = listOf( val TYPES = listOf(
ConfigurationItemType.OwnDeviceId, ConfigurationItemType.OwnDeviceId,
@ -156,7 +162,11 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.CustomOrganizationName, ConfigurationItemType.CustomOrganizationName,
ConfigurationItemType.ServerApiLevel, ConfigurationItemType.ServerApiLevel,
ConfigurationItemType.AnnoyManualUnblockCounter, ConfigurationItemType.AnnoyManualUnblockCounter,
ConfigurationItemType.ConsentFlags ConfigurationItemType.ConsentFlags,
ConfigurationItemType.SigningKey,
ConfigurationItemType.SignSequenceNumber,
ConfigurationItemType.LastServerKeyRequestSequence,
ConfigurationItemType.LastKeyResponseSequence
) )
fun serialize(value: ConfigurationItemType) = when(value) { fun serialize(value: ConfigurationItemType) = when(value) {
@ -185,6 +195,10 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.ServerApiLevel -> SERVER_API_LEVEL ConfigurationItemType.ServerApiLevel -> SERVER_API_LEVEL
ConfigurationItemType.AnnoyManualUnblockCounter -> ANNOY_MANUAL_UNBLOCK_COUNTER ConfigurationItemType.AnnoyManualUnblockCounter -> ANNOY_MANUAL_UNBLOCK_COUNTER
ConfigurationItemType.ConsentFlags -> CONSENT_FLAGS ConfigurationItemType.ConsentFlags -> CONSENT_FLAGS
ConfigurationItemType.SigningKey -> SIGNING_KEY
ConfigurationItemType.SignSequenceNumber -> SIGN_SEQUENCE_NUMBER
ConfigurationItemType.LastServerKeyRequestSequence -> LAST_SERVER_KEY_REQUEST_SEQUENCE
ConfigurationItemType.LastKeyResponseSequence -> LAST_SERVER_KEY_RESPONSE_SEQUENCE
} }
fun parse(value: Int) = when(value) { fun parse(value: Int) = when(value) {
@ -213,6 +227,10 @@ object ConfigurationItemTypeUtil {
SERVER_API_LEVEL -> ConfigurationItemType.ServerApiLevel SERVER_API_LEVEL -> ConfigurationItemType.ServerApiLevel
ANNOY_MANUAL_UNBLOCK_COUNTER -> ConfigurationItemType.AnnoyManualUnblockCounter ANNOY_MANUAL_UNBLOCK_COUNTER -> ConfigurationItemType.AnnoyManualUnblockCounter
CONSENT_FLAGS -> ConfigurationItemType.ConsentFlags CONSENT_FLAGS -> ConfigurationItemType.ConsentFlags
SIGNING_KEY -> ConfigurationItemType.SigningKey
SIGN_SEQUENCE_NUMBER -> ConfigurationItemType.SignSequenceNumber
LAST_SERVER_KEY_REQUEST_SEQUENCE -> ConfigurationItemType.LastServerKeyRequestSequence
LAST_SERVER_KEY_RESPONSE_SEQUENCE -> ConfigurationItemType.LastKeyResponseSequence
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }
@ -256,6 +274,7 @@ object ExperimentalFlags {
// const val INSTANCE_ID_FG_APP_DETECTION = 65536L // const val INSTANCE_ID_FG_APP_DETECTION = 65536L
// private const val OBSOLETE_DISABLE_FG_APP_DETECTION_FALLBACK = 131072L // private const val OBSOLETE_DISABLE_FG_APP_DETECTION_FALLBACK = 131072L
const val STRICT_OVERLAY_CHECKING = 0x40000L const val STRICT_OVERLAY_CHECKING = 0x40000L
const val DISABLE_LEGACY_APP_SENDING = 0x80000L
} }
object ConsentFlags { object ConsentFlags {

View file

@ -0,0 +1,41 @@
/*
* 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.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "crypt_container_data",
foreignKeys = [
ForeignKey(
entity = CryptContainerMetadata::class,
childColumns = ["crypt_container_id"],
parentColumns = ["crypt_container_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
]
)
data class CryptContainerData (
@PrimaryKey
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "encrypted_data")
val encryptedData: ByteArray
)

View file

@ -0,0 +1,64 @@
/*
* 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.data.model
import androidx.room.*
@Entity(
tableName = "crypt_container_key_result",
primaryKeys = ["request_sequence_id", "device_id"],
foreignKeys = [
ForeignKey(
entity = CryptContainerPendingKeyRequest::class,
childColumns = ["request_sequence_id"],
parentColumns = ["request_sequence_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Device::class,
childColumns = ["device_id"],
parentColumns = ["id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
@TypeConverters(CryptContainerKeyResultStatusConverter::class)
data class CryptContainerKeyResult (
@ColumnInfo(name = "request_sequence_id", index = true)
val requestSequenceId: Long,
@ColumnInfo(name = "device_id", index = true)
val deviceId: String,
val status: Status
) {
enum class Status {
InvalidKey
}
}
class CryptContainerKeyResultStatusConverter {
@TypeConverter
fun toStatus(input: Int): CryptContainerKeyResult.Status = when (input) {
0 -> CryptContainerKeyResult.Status.InvalidKey
else -> CryptContainerKeyResult.Status.InvalidKey
}
@TypeConverter
fun toInt(input: CryptContainerKeyResult.Status): Int = when (input) {
CryptContainerKeyResult.Status.InvalidKey -> 0
}
}

View file

@ -0,0 +1,163 @@
/*
* 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.data.model
import androidx.room.*
import io.timelimit.android.crypto.CryptContainer
@Entity(
tableName = "crypt_container_metadata",
foreignKeys = [
ForeignKey(
entity = Device::class,
childColumns = ["device_id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
),
ForeignKey(
entity = Category::class,
childColumns = ["category_id"],
parentColumns = ["id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
]
)
@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class)
data class CryptContainerMetadata (
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "device_id", index = true)
val deviceId: String?,
@ColumnInfo(name = "category_id", index = true)
val categoryId: String?,
val type: Int,
@ColumnInfo(name = "server_version")
val serverVersion: String,
@ColumnInfo(name = "current_generation")
val currentGeneration: Long,
@ColumnInfo(name = "current_generation_first_timestamp")
val currentGenerationFirstTimestamp: Long,
@ColumnInfo(name = "next_counter")
val nextCounter: Long,
@ColumnInfo(name = "current_generation_key")
val currentGenerationKey: ByteArray?,
val status: ProcessingStatus
) {
companion object {
private const val GENERATION_DURATION_LIMIT = 1000 * 60 * 60 * 24 * 7 // 7 days
private const val GENERATION_COUNTER_LIMIT = 16L
const val TYPE_APP_LIST_BASE = 1
const val TYPE_APP_LIST_DIFF = 2
fun buildFor(deviceId: String?, categoryId: String?, type: Int, params: CryptContainer.EncryptParameters) = CryptContainerMetadata(
cryptContainerId = 0,
deviceId = deviceId,
categoryId = categoryId,
type = type,
serverVersion = "",
currentGeneration = params.generation,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
nextCounter = params.counter + 1,
currentGenerationKey = params.key,
status = ProcessingStatus.Finished
)
fun isTypeValid(type: Int) = type == TYPE_APP_LIST_BASE || type == TYPE_APP_LIST_DIFF
}
enum class ProcessingStatus {
MissingKey,
DowngradeDetected,
Unprocessed,
CryptoDamage,
ContentDamage,
Finished
}
fun needsNewGeneration(): Boolean {
val now = System.currentTimeMillis()
val timeWentBackwards = now < currentGenerationFirstTimestamp
val timeLimitReached = now >= currentGenerationFirstTimestamp + GENERATION_DURATION_LIMIT
val counterLimitReached = nextCounter >= GENERATION_COUNTER_LIMIT
return timeWentBackwards or timeLimitReached or counterLimitReached
}
fun prepareEncryption(forceNewGeneration: Boolean): PrepareEncryptionResult {
return if (needsNewGeneration() || forceNewGeneration || currentGenerationKey == null) {
val newKey = CryptContainer.generateKey()
val generation = currentGeneration + 1
PrepareEncryptionResult(
params = CryptContainer.EncryptParameters(
key = newKey,
generation = generation,
counter = 0
),
newMetadata = copy(
currentGeneration = generation,
nextCounter = 1,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
currentGenerationKey = newKey
)
)
} else {
PrepareEncryptionResult(
params = CryptContainer.EncryptParameters(
key = currentGenerationKey,
generation = currentGeneration,
counter = nextCounter
),
newMetadata = copy(
nextCounter = nextCounter + 1
)
)
}
}
data class PrepareEncryptionResult (
val params: CryptContainer.EncryptParameters,
val newMetadata: CryptContainerMetadata
)
}
class CryptContainerMetadataProcessingStatusConverter {
@TypeConverter
fun toStatus(input: Int): CryptContainerMetadata.ProcessingStatus = when (input) {
0 -> CryptContainerMetadata.ProcessingStatus.MissingKey
1 -> CryptContainerMetadata.ProcessingStatus.DowngradeDetected
2 -> CryptContainerMetadata.ProcessingStatus.Unprocessed
3 -> CryptContainerMetadata.ProcessingStatus.CryptoDamage
4 -> CryptContainerMetadata.ProcessingStatus.ContentDamage
5 -> CryptContainerMetadata.ProcessingStatus.Finished
else -> CryptContainerMetadata.ProcessingStatus.CryptoDamage
}
@TypeConverter
fun toInt(input: CryptContainerMetadata.ProcessingStatus): Int = when (input) {
CryptContainerMetadata.ProcessingStatus.MissingKey -> 0
CryptContainerMetadata.ProcessingStatus.DowngradeDetected -> 1
CryptContainerMetadata.ProcessingStatus.Unprocessed -> 2
CryptContainerMetadata.ProcessingStatus.CryptoDamage -> 3
CryptContainerMetadata.ProcessingStatus.ContentDamage -> 4
CryptContainerMetadata.ProcessingStatus.Finished -> 5
}
}

View file

@ -0,0 +1,48 @@
/*
* 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.data.model
import androidx.room.*
@Entity(
tableName = "crypt_container_pending_key_request",
foreignKeys = [
ForeignKey(
entity = CryptContainerMetadata::class,
childColumns = ["crypt_container_id"],
parentColumns = ["crypt_container_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)
],
indices = [
Index(
unique = true,
value = ["request_sequence_id"]
)
]
)
data class CryptContainerPendingKeyRequest (
@PrimaryKey
@ColumnInfo(name = "crypt_container_id")
val cryptContainerId: Long,
@ColumnInfo(name = "request_time_crypt_container_generation")
val requestTimeCryptContainerGeneration: Long,
@ColumnInfo(name = "request_sequence_id")
val requestSequenceId: Long,
@ColumnInfo(name = "request_key")
val requestKey: ByteArray
)

View file

@ -0,0 +1,43 @@
/*
* 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.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "device_public_key",
foreignKeys = [
ForeignKey(
entity = Device::class,
parentColumns = ["id"],
childColumns = ["device_id"],
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)
]
)
data class DevicePublicKey (
@PrimaryKey
@ColumnInfo(name = "device_id")
val deviceId: String,
@ColumnInfo(name = "public_key")
val publicKey: ByteArray,
@ColumnInfo(name = "next_sequence_number")
val nextSequenceNumber: Long
)

View file

@ -22,6 +22,7 @@ import androidx.room.*
import io.timelimit.android.crypto.Curve25519 import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.extensions.parseBase64
@Entity( @Entity(
tableName = "user_key", tableName = "user_key",
@ -64,7 +65,7 @@ data class UserKey(
while (reader.hasNext()) { while (reader.hasNext()) {
when (reader.nextName()) { when (reader.nextName()) {
USER_ID -> userId = reader.nextString() USER_ID -> userId = reader.nextString()
PUBLIC_KEY -> publicKey = Base64.decode(reader.nextString(), 0) PUBLIC_KEY -> publicKey = reader.nextString().parseBase64()
LAST_USE -> lastUse = reader.nextLong() LAST_USE -> lastUse = reader.nextLong()
else -> reader.skipValue() else -> reader.skipValue()
} }

View file

@ -0,0 +1,21 @@
/*
* 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 android.util.Base64
fun String.parseBase64() = Base64.decode(this, Base64.DEFAULT)
fun ByteArray.base64() = Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP)

View file

@ -91,6 +91,8 @@ abstract class PlatformIntegration(
abstract fun getExitLog(length: Int): List<ExitLogItem> abstract fun getExitLog(length: Int): List<ExitLogItem>
abstract fun showNewDeviceNotification(title: String)
var installedAppsChangeListener: Runnable? = null var installedAppsChangeListener: Runnable? = null
var systemClockChangeListener: Runnable? = null var systemClockChangeListener: Runnable? = null
} }

View file

@ -62,6 +62,7 @@ import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -821,4 +822,25 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
.map { ExitLogItem.fromApplicationExitInfo(it) } .map { ExitLogItem.fromApplicationExitInfo(it) }
} else emptyList() } else emptyList()
} }
override fun showNewDeviceNotification(title: String) {
NotificationChannels.createNotificationChannels(notificationManager, context)
notificationManager.notify(
UUID.randomUUID().toString(),
NotificationIds.NEW_DEVICE,
NotificationCompat.Builder(context, NotificationChannels.NEW_DEVICE)
.setSmallIcon(R.drawable.ic_stat_timelapse)
.setContentTitle(context.getString(R.string.notification_new_device_title))
.setContentText(title)
.setContentIntent(BackgroundActionService.getOpenAppIntent(context))
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
.setLocalOnly(true)
.setAutoCancel(false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
)
}
} }

View file

@ -33,6 +33,7 @@ object NotificationIds {
const val LOCAL_UPDATE_NOTIFICATION = 7 const val LOCAL_UPDATE_NOTIFICATION = 7
const val WORKER_REPORT_UNINSTALL = 8 const val WORKER_REPORT_UNINSTALL = 8
const val WORKER_SYNC_BACKGROUND = 9 const val WORKER_SYNC_BACKGROUND = 9
const val NEW_DEVICE = 10
} }
object NotificationChannels { object NotificationChannels {
@ -45,6 +46,7 @@ object NotificationChannels {
const val BACKGROUND_SYNC_NOTIFICATION = "background sync" const val BACKGROUND_SYNC_NOTIFICATION = "background sync"
const val TEMP_ALLOWED_APP = "temporarily allowed App" const val TEMP_ALLOWED_APP = "temporarily allowed App"
const val APP_RESET = "app reset" const val APP_RESET = "app reset"
const val NEW_DEVICE = "new device"
private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) { private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -193,6 +195,25 @@ object NotificationChannels {
} }
} }
private fun createNewDeviceChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NEW_DEVICE,
context.getString(R.string.notification_channel_new_device_title),
NotificationManager.IMPORTANCE_LOW
).apply {
description = context.getString(R.string.notification_channel_new_device_description)
enableLights(false)
setSound(null, null)
enableVibration(false)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_SECRET
}
)
}
}
fun createNotificationChannels(notificationManager: NotificationManager, context: Context) { fun createNotificationChannels(notificationManager: NotificationManager, context: Context) {
createAppStatusChannel(notificationManager, context) createAppStatusChannel(notificationManager, context)
createBlockedNotificationChannel(notificationManager, context) createBlockedNotificationChannel(notificationManager, context)
@ -203,6 +224,7 @@ object NotificationChannels {
createBackgroundSyncChannel(notificationManager, context) createBackgroundSyncChannel(notificationManager, context)
createTempAllowedAppChannel(notificationManager, context) createTempAllowedAppChannel(notificationManager, context)
createAppResetChannel(notificationManager, context) createAppResetChannel(notificationManager, context)
createNewDeviceChannel(notificationManager, context)
} }
} }

View file

@ -191,4 +191,6 @@ class DummyIntegration(
): Boolean = false ): Boolean = false
override fun getExitLog(length: Int): List<ExitLogItem> = emptyList() override fun getExitLog(length: Int): List<ExitLogItem> = emptyList()
override fun showNewDeviceNotification(title: String) = Unit
} }

View file

@ -167,6 +167,8 @@ fun <T1, T2> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>): Liv
return result return result
} }
data class SevenTuple<A, B, C, D, E, F, G>(val first: A, val second: B, val third: C, val forth: D, val fifth: E, val sixth: F, val seventh: G)
fun <T1, T2, T3> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>): LiveData<Triple<T1, T2, T3>> { fun <T1, T2, T3> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>): LiveData<Triple<T1, T2, T3>> {
val result = MediatorLiveData<Triple<T1, T2, T3>>() val result = MediatorLiveData<Triple<T1, T2, T3>>()
var state = Triple<Option<T1>, Option<T2>, Option<T3>>(Option.None(), Option.None(), Option.None()) var state = Triple<Option<T1>, Option<T2>, Option<T3>>(Option.None(), Option.None(), Option.None())
@ -281,5 +283,113 @@ fun <T1, T2, T3, T4, T5> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveDa
update() update()
} }
return result
}
fun <T1, T2, T3, T4, T5, T6> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>, d4: LiveData<T4>, d5: LiveData<T5>, d6: LiveData<T6>): LiveData<SixTuple<T1, T2, T3, T4, T5, T6>> {
val result = MediatorLiveData<SixTuple<T1, T2, T3, T4, T5, T6>>()
var state = SixTuple<Option<T1>, Option<T2>, Option<T3>, Option<T4>, Option<T5>, Option<T6>>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None())
fun update() {
val (a, b, c, d, e, f) = state
if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some) {
result.value = SixTuple(a.value, b.value, c.value, d.value, e.value, f.value)
}
}
result.addSource(d1) {
state = state.copy(first = Option.Some(it))
update()
}
result.addSource(d2) {
state = state.copy(second = Option.Some(it))
update()
}
result.addSource(d3) {
state = state.copy(third = Option.Some(it))
update()
}
result.addSource(d4) {
state = state.copy(forth = Option.Some(it))
update()
}
result.addSource(d5) {
state = state.copy(fifth = Option.Some(it))
update()
}
result.addSource(d6) {
state = state.copy(sixth = Option.Some(it))
update()
}
return result
}
fun <T1, T2, T3, T4, T5, T6, T7> mergeLiveDataWaitForValues(d1: LiveData<T1>, d2: LiveData<T2>, d3: LiveData<T3>, d4: LiveData<T4>, d5: LiveData<T5>, d6: LiveData<T6>, d7: LiveData<T7>): LiveData<SevenTuple<T1, T2, T3, T4, T5, T6, T7>> {
val result = MediatorLiveData<SevenTuple<T1, T2, T3, T4, T5, T6, T7>>()
var state = SevenTuple<Option<T1>, Option<T2>, Option<T3>, Option<T4>, Option<T5>, Option<T6>, Option<T7>>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None())
fun update() {
val (a, b, c, d, e, f, g) = state
if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some && g is Option.Some) {
result.value = SevenTuple(a.value, b.value, c.value, d.value, e.value, f.value, g.value)
}
}
result.addSource(d1) {
state = state.copy(first = Option.Some(it))
update()
}
result.addSource(d2) {
state = state.copy(second = Option.Some(it))
update()
}
result.addSource(d3) {
state = state.copy(third = Option.Some(it))
update()
}
result.addSource(d4) {
state = state.copy(forth = Option.Some(it))
update()
}
result.addSource(d5) {
state = state.copy(fifth = Option.Some(it))
update()
}
result.addSource(d6) {
state = state.copy(sixth = Option.Some(it))
update()
}
result.addSource(d7) {
state = state.copy(seventh = Option.Some(it))
update()
}
return result return result
} }

View file

@ -21,14 +21,13 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.ExperimentalFlags
import io.timelimit.android.data.model.User import io.timelimit.android.data.model.User
import io.timelimit.android.integration.platform.PlatformIntegration import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.integration.time.TimeApi import io.timelimit.android.integration.time.TimeApi
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.logic.applist.SyncInstalledAppsLogic
import io.timelimit.android.sync.SyncUtil import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.network.api.ServerApi import io.timelimit.android.sync.network.api.ServerApi
import io.timelimit.android.sync.websocket.NetworkStatus
import io.timelimit.android.sync.websocket.NetworkStatusInterface import io.timelimit.android.sync.websocket.NetworkStatusInterface
import io.timelimit.android.sync.websocket.WebsocketClientCreator import io.timelimit.android.sync.websocket.WebsocketClientCreator

View file

@ -15,16 +15,26 @@
*/ */
package io.timelimit.android.logic package io.timelimit.android.logic
import io.timelimit.android.data.Database
import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap import io.timelimit.android.livedata.switchMap
class ServerApiLevelLogic(logic: AppLogic) { class ServerApiLevelLogic(logic: AppLogic) {
val infoLive = logic.database.config().getDeviceAuthTokenAsync().switchMap { authToken -> companion object {
fun getSync(database: Database): ServerApiLevelInfo = if (database.config().getDeviceAuthTokenSync().isEmpty())
ServerApiLevelInfo.Offline
else
ServerApiLevelInfo.Online(serverLevel = database.config().getServerApiLevelSync())
}
private val database = logic.database
val infoLive = database.config().getDeviceAuthTokenAsync().switchMap { authToken ->
if (authToken.isEmpty()) if (authToken.isEmpty())
liveDataFromNonNullValue(ServerApiLevelInfo.Offline) liveDataFromNonNullValue(ServerApiLevelInfo.Offline)
else else
logic.database.config().getServerApiLevelLive().map { apiLevel -> database.config().getServerApiLevelLive().map { apiLevel ->
ServerApiLevelInfo.Online(serverLevel = apiLevel) ServerApiLevelInfo.Online(serverLevel = apiLevel)
} }
} }

View file

@ -1,261 +0,0 @@
/*
* 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.logic
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.ConsentFlags
import io.timelimit.android.data.model.UserType
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SyncInstalledAppsLogic(val appLogic: AppLogic) {
companion object {
private const val LOG_TAG = "SyncInstalledAppsLogic"
}
private val doSyncLock = Mutex()
private var requestSync = MutableLiveData<Boolean>().apply { value = false }
private fun requestSync() {
requestSync.value = true
}
private val deviceStateLive = mergeLiveDataWaitForValues(
appLogic.deviceEntryIfEnabled,
appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC),
appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() },
appLogic.deviceUserEntry,
appLogic.deviceEntryIfEnabled.switchMap { deviceEntry ->
val defaultUser = deviceEntry?.defaultUser
if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null)
else appLogic.database.user().getUserByIdLive(defaultUser)
}
).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser) ->
deviceEntry?.let { device ->
DeviceState(
id = device.id,
isCurrentUserChild = deviceUser?.type == UserType.Child,
isDefaultUserChild = deviceDefaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = hasSyncConsent,
isLocalMode = isLocalMode
)
}
}.ignoreUnchanged()
val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged()
private fun getDeviceStateSync(): DeviceState? {
val userAndDeviceData = appLogic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null
val deviceRelatedData = userAndDeviceData.deviceRelatedData
val device = deviceRelatedData.deviceEntry
val defaultUser = if (device.defaultUser.isNotEmpty()) appLogic.database.user().getUserByIdSync(device.defaultUser) else null
return DeviceState(
id = device.id,
isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child,
isDefaultUserChild = defaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC,
isLocalMode = deviceRelatedData.isLocalMode
)
}
init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() }
deviceStateLive.observeForever { requestSync() }
runAsyncExpectForever { syncLoop() }
}
private suspend fun syncLoop() {
// wait a moment before the first sync
appLogic.timeApi.sleep(15 * 1000)
while (true) {
requestSync.waitUntilValueMatches { it == true }
requestSync.value = false
try {
doSyncNow()
// maximal 1 time per 5 seconds
appLogic.timeApi.sleep(5 * 1000)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "could not sync installed app list", ex)
}
Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show()
appLogic.timeApi.sleep(45 * 1000)
requestSync.value = true
}
}
}
private suspend fun doSyncNow() {
doSyncLock.withLock {
val deviceState = Threads.database.executeAndWait { getDeviceStateSync() } ?: return
if (deviceState.isLocalMode) {
// local mode -> sync always
} else {
// connected mode -> don't sync always
if (!deviceState.hasSyncConsent) return@withLock
if (!deviceState.hasAnyChildUser) return@withLock
}
val deviceId = deviceState.id
val currentlyInstalledApps = getCurrentApps(deviceId)
run {
val currentlySaved = appLogic.database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().associateBy { app -> app.packageName }
// skip all items for removal which are still saved locally
val itemsToRemove = HashMap(currentlySaved)
currentlyInstalledApps.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
// only add items which are not the same locally
val itemsToAdd = currentlyInstalledApps.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes
if (itemsToRemove.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
if (itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = AddInstalledAppsAction(
apps = itemsToAdd.map {
(_, app) ->
InstalledApp(
packageName = app.packageName,
title = app.title,
recommendation = app.recommendation,
isLaunchable = app.isLaunchable
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
run {
fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}"
val currentlyInstalled = if (deviceState.enableActivityLevelBlocking)
Threads.backgroundOSInteraction.executeAndWait {
val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId)
val dummyActivities = currentlyInstalledApps.keys.map { packageName ->
AppActivity(
deviceId = deviceId,
appPackageName = packageName,
activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO,
title = appLogic.context.getString(R.string.dummy_app_activity_audio)
)
}
val allActivities = realActivities + dummyActivities
allActivities.associateBy { buildKey(it) }
}
else
emptyMap()
val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) }
// skip all items for removal which are still saved locally
val itemsToRemove = HashMap(currentlySaved)
currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
// only add items which are not the same locally
val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes
if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = UpdateAppActivitiesAction(
removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName },
updatedOrAddedActivities = itemsToAdd.map { item ->
AppActivityItem(
packageName = item.value.appPackageName,
className = item.value.activityClassName,
title = item.value.title
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
}
}
private suspend fun getCurrentApps(deviceId: String): Map<String, App> {
val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait {
appLogic.platformIntegration.getLocalApps(deviceId = deviceId).associateBy { app -> app.packageName }
}
val featureDummyApps = appLogic.platformIntegration.getFeatures().map {
DummyApps.forFeature(
id = it.id,
title = it.title,
deviceId = deviceId
)
}.associateBy { it.packageName }
return currentlyInstalled + featureDummyApps
}
internal data class DeviceState(
val id: String,
val isCurrentUserChild: Boolean,
val isDefaultUserChild: Boolean,
val enableActivityLevelBlocking: Boolean,
val isDeviceOwner: Boolean,
val hasSyncConsent: Boolean,
val isLocalMode: Boolean
) {
val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild
val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent
}
}

View file

@ -0,0 +1,74 @@
/*
* 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.logic.applist
import io.timelimit.android.proto.toAppActivityItem
import io.timelimit.android.proto.toInstalledApp
import io.timelimit.android.sync.actions.AddInstalledAppsAction
import io.timelimit.android.sync.actions.AppLogicAction
import io.timelimit.android.sync.actions.RemoveInstalledAppsAction
import io.timelimit.android.sync.actions.UpdateAppActivitiesAction
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.RemovedAppActivityProto
object AppsDifferenceUtil {
fun calculateAppsDifference(old: InstalledAppsProto, current: InstalledAppsProto): InstalledAppsDifferenceProto {
val oldAppsByPackageName = old.apps.associateBy { it.package_name }
val packageNamesToRemove = (oldAppsByPackageName.keys - current.apps.map { it.package_name }.toSet()).toList()
val appsToAdd = current.apps.filter { app -> oldAppsByPackageName[app.package_name] != app }
val oldActivitiesIndexed = old.activities.associateBy { Pair(it.package_name, it.class_name) }
val currentActivitiesIndexed = current.activities.associateBy { Pair(it.package_name, it.class_name) }
val activitiesToRemove = (oldActivitiesIndexed.keys - currentActivitiesIndexed.keys)
.map { activity -> RemovedAppActivityProto(package_name = activity.first, class_name = activity.second) }
val activitiesToAdd = currentActivitiesIndexed.filter { (key, activity) -> oldActivitiesIndexed[key] != activity }
.values.toList()
return InstalledAppsDifferenceProto(
added = InstalledAppsProto(
apps = appsToAdd,
activities = activitiesToAdd
),
removed_packages = packageNamesToRemove,
removed_activities = activitiesToRemove
)
}
fun calculateAppsDifferenceActions(difference: InstalledAppsDifferenceProto, deviceId: String): List<AppLogicAction> {
val result = mutableListOf<AppLogicAction>()
if (difference.removed_packages.isNotEmpty()) {
result.add(RemoveInstalledAppsAction(packageNames = difference.removed_packages))
}
if (difference.added != null && difference.added.apps.isNotEmpty()) {
result.add(AddInstalledAppsAction(apps = difference.added.apps.map { it.toInstalledApp() }))
}
val addedActivities = difference.added?.activities ?: emptyList()
val removedActivities = difference.removed_activities
if (addedActivities.isNotEmpty() || removedActivities.isNotEmpty()) {
result.add(UpdateAppActivitiesAction(
removedActivities = removedActivities.map { it.package_name to it.class_name },
updatedOrAddedActivities = addedActivities.map { it.toAppActivityItem() }
))
}
return result
}
}

View file

@ -0,0 +1,193 @@
/*
* 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.logic.applist
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
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.proto.build
import io.timelimit.android.proto.encodeDeflated
import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.actions.AppLogicAction
import io.timelimit.android.sync.actions.UpdateInstalledAppsAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
object CryptoAppListSync {
private const val SIZE_LIMIT_COMPRESSED = 1024 * 256
class TooLargeException: RuntimeException()
suspend fun sync(
deviceState: DeviceState,
database: Database,
installed: InstalledAppsProto,
syncUtil: SyncUtil,
disableLegacySync: Boolean
) {
fun dispatch(action: AppLogicAction) {
if (deviceState.isConnectedMode) {
ApplyActionUtil.addAppLogicActionToDatabaseSync(action, database)
}
}
val savedCrypt = Threads.database.executeAndWait {
InstalledAppsUtil.getEncryptedInstalledAppsFromDatabaseSync(database, deviceState.id)
}
if (savedCrypt == null) {
val baseKey = CryptContainer.EncryptParameters.generate()
val diffKey = CryptContainer.EncryptParameters.generate()
val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseKey)
val diffEncrypted = CryptContainer.encrypt(SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(), diffKey)
if (baseEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
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
)
)
database.cryptContainer().insertData(
CryptContainerData(
cryptContainerId = baseId,
encryptedData = baseEncrypted
)
)
database.cryptContainer().insertData(
CryptContainerData(
cryptContainerId = diffId,
encryptedData = diffEncrypted
)
)
dispatch(UpdateInstalledAppsAction(
base = baseEncrypted,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
} else {
val diffCrypto = AppsDifferenceUtil.calculateAppsDifference(savedCrypt.base, installed)
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 > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
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,
encryptedData = diffEncrypted
)
))
dispatch(UpdateInstalledAppsAction(
base = baseEncrypted,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
} else {
val diffEncrypted = CryptContainer.encrypt(
SavedAppsDifferenceProto.build(savedCrypt.baseHeader, diffCrypto).encodeDeflated(),
diffCryptParams.params
)
if (diffEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException()
Threads.database.executeAndWait {
database.cryptContainer().updateMetadata(diffCryptParams.newMetadata)
database.cryptContainer().updateData(
CryptContainerData(
cryptContainerId = savedCrypt.diffMeta.cryptContainerId,
encryptedData = diffEncrypted
)
)
dispatch(UpdateInstalledAppsAction(
base = null,
diff = diffEncrypted,
wipe = disableLegacySync
))
}
syncUtil.requestImportantSync()
}
}
}
}
}

View file

@ -0,0 +1,95 @@
/*
* 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.logic.applist
import androidx.lifecycle.LiveData
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.ConsentFlags
import io.timelimit.android.data.model.ExperimentalFlags
import io.timelimit.android.data.model.UserType
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.ServerApiLevelInfo
import io.timelimit.android.logic.ServerApiLevelLogic
data class DeviceState(
val id: String,
val isCurrentUserChild: Boolean,
val isDefaultUserChild: Boolean,
val enableActivityLevelBlocking: Boolean,
val isDeviceOwner: Boolean,
val hasSyncConsent: Boolean,
val isLocalMode: Boolean,
val disableLegacySync: Boolean,
val serverApiLevel: ServerApiLevelInfo
) {
companion object {
fun getSync(database: Database): DeviceState? {
val userAndDeviceData = database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null
val deviceRelatedData = userAndDeviceData.deviceRelatedData
val device = deviceRelatedData.deviceEntry
val defaultUser = if (device.defaultUser.isNotEmpty()) database.user().getUserByIdSync(device.defaultUser) else null
val disableLegacySync = deviceRelatedData.isExperimentalFlagSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING)
val serverApiLevel = ServerApiLevelLogic.getSync(database)
return DeviceState(
id = device.id,
isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child,
isDefaultUserChild = defaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC,
isLocalMode = deviceRelatedData.isLocalMode,
disableLegacySync = disableLegacySync,
serverApiLevel = serverApiLevel
)
}
fun getLive(appLogic: AppLogic): LiveData<DeviceState?> = mergeLiveDataWaitForValues(
appLogic.deviceEntryIfEnabled,
appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC),
appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() },
appLogic.deviceUserEntry,
appLogic.deviceEntryIfEnabled.switchMap { deviceEntry ->
val defaultUser = deviceEntry?.defaultUser
if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null)
else appLogic.database.user().getUserByIdLive(defaultUser)
},
appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING),
appLogic.serverApiLevelLogic.infoLive
).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser, disableLegacySync, serverApiLevel) ->
deviceEntry?.let { device ->
DeviceState(
id = device.id,
isCurrentUserChild = deviceUser?.type == UserType.Child,
isDefaultUserChild = deviceDefaultUser?.type == UserType.Child,
enableActivityLevelBlocking = device.enableActivityLevelBlocking,
isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner,
hasSyncConsent = hasSyncConsent,
isLocalMode = isLocalMode,
disableLegacySync = disableLegacySync,
serverApiLevel = serverApiLevel
)
}
}.ignoreUnchanged()
}
val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild
val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent
val isConnectedMode = !isLocalMode
}

View file

@ -0,0 +1,171 @@
/*
* 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.logic.applist
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DummyApps
import io.timelimit.android.proto.decodeInflated
import io.timelimit.android.proto.toProto
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
import java.io.IOException
object InstalledAppsUtil {
private const val LOG_TAG = "InstalledAppsUtil"
suspend fun getInstalledAppsFromPlainDatabaseAsync(database: Database, deviceId: String): InstalledAppsProto {
return InstalledAppsProto(
apps = database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().map { it.toProto() },
activities = database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().map { it.toProto() }
)
}
fun getEncryptedInstalledAppsFromDatabaseSync(database: Database, deviceId: String): DecryptedInstalledApps? {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "getEncryptedInstalledAppsFromDatabaseSync()")
}
val baseValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId(
deviceId = deviceId,
type = CryptContainerMetadata.TYPE_APP_LIST_BASE
)
val diffValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId(
deviceId = deviceId,
type = CryptContainerMetadata.TYPE_APP_LIST_DIFF
)
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
) {
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(
baseValue.metadata.currentGenerationKey,
baseValue.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
?: InstalledAppsDifferenceProto()
Pair(base, diff)
} catch (ex: IOException) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "could not decode data", ex)
}
return null
}
return DecryptedInstalledApps(
base = base,
baseMeta = baseValue.metadata,
baseHeader = baseHeader,
diff = diff,
diffMeta = diffValue.metadata
)
}
data class DecryptedInstalledApps(
val base: InstalledAppsProto,
val baseHeader: CryptContainer.Header,
val baseMeta: CryptContainerMetadata,
val diff: InstalledAppsDifferenceProto,
val diffMeta: CryptContainerMetadata
)
suspend fun getInstalledAppsFromOs(appLogic: AppLogic, deviceState: DeviceState): InstalledAppsProto {
val apps = kotlin.run {
val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait {
appLogic.platformIntegration.getLocalApps(deviceId = deviceState.id)
}
val featureDummyApps = appLogic.platformIntegration.getFeatures().map {
DummyApps.forFeature(
id = it.id,
title = it.title,
deviceId = deviceState.id
)
}
(currentlyInstalled + featureDummyApps).map { it.toProto() }
}
val activities = if (deviceState.enableActivityLevelBlocking)
Threads.backgroundOSInteraction.executeAndWait {
val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceState.id)
val dummyActivities = apps.map { app ->
AppActivity(
deviceId = deviceState.id,
appPackageName = app.package_name,
activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO,
title = appLogic.context.getString(R.string.dummy_app_activity_audio)
)
}
(realActivities + dummyActivities).map { it.toProto() }
}
else
emptyList()
return InstalledAppsProto(
apps = apps,
activities = activities
)
}
}

View file

@ -0,0 +1,132 @@
/*
* 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.logic.applist
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SyncInstalledAppsLogic(val appLogic: AppLogic) {
companion object {
private const val LOG_TAG = "SyncInstalledAppsLogic"
}
private val doSyncLock = Mutex()
private var requestSync = MutableLiveData<Boolean>().apply { value = false }
private fun requestSync() {
requestSync.value = true
}
private val deviceStateLive = DeviceState.getLive(appLogic)
val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged()
init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() }
deviceStateLive.observeForever { requestSync() }
runAsyncExpectForever { syncLoop() }
}
private suspend fun syncLoop() {
// wait a moment before the first sync
appLogic.timeApi.sleep(15 * 1000)
while (true) {
requestSync.waitUntilValueMatches { it == true }
requestSync.value = false
try {
doSyncNow()
// maximal 1 time per 5 seconds
appLogic.timeApi.sleep(5 * 1000)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "could not sync installed app list", ex)
}
Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show()
appLogic.timeApi.sleep(45 * 1000)
requestSync.value = true
}
}
}
private suspend fun doSyncNow() {
doSyncLock.withLock {
val deviceState = Threads.database.executeAndWait { DeviceState.getSync(appLogic.database) } ?: return
if (deviceState.isLocalMode) {
// local mode -> sync always
} else {
// connected mode -> don't sync always
if (!deviceState.hasSyncConsent) return@withLock
if (!deviceState.hasAnyChildUser) return@withLock
}
val installed = InstalledAppsUtil.getInstalledAppsFromOs(appLogic, deviceState)
val savedPlain = InstalledAppsUtil.getInstalledAppsFromPlainDatabaseAsync(appLogic.database, deviceState.id)
val diffPlain = AppsDifferenceUtil.calculateAppsDifference(savedPlain, installed)
val diffPlainActions = AppsDifferenceUtil.calculateAppsDifferenceActions(diffPlain, deviceState.id)
if (deviceState.disableLegacySync) {
if (diffPlainActions.isNotEmpty()) {
Threads.database.executeAndWait {
diffPlainActions.forEach {
LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(
it, appLogic.database.config().getOwnDeviceIdSync()!!, appLogic.database
)
}
}
}
} else {
diffPlainActions.forEach { action ->
ApplyActionUtil.applyAppLogicAction(
action = action,
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
if (deviceState.isConnectedMode && deviceState.serverApiLevel.hasLevelOrIsOffline(4)) {
CryptoAppListSync.sync(
deviceState = deviceState,
database = appLogic.database,
installed = installed,
syncUtil = appLogic.syncUtil,
disableLegacySync = deviceState.disableLegacySync
)
}
}
}
}

View file

@ -0,0 +1,157 @@
/*
* 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.logic.crypto
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.*
import io.timelimit.android.sync.actions.SendKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerCryptContainer
object CryptDataHandler {
fun process(database: Database, data: ServerCryptContainer, type: Int, deviceId: String?, categoryId: String?): Result {
val oldItem = if (deviceId != null)
database.cryptContainer().getCryptoMetadataSyncByDeviceId(deviceId, type)
else if (categoryId != null)
database.cryptContainer().getCryptoMetadataSyncByCategoryId(categoryId, type)
else
database.cryptContainer().getCryptoMetadataSyncByType(type)
val isUnmodified = if (oldItem != null) {
val oldSavedData = database.cryptContainer().getData(oldItem.cryptContainerId)
oldSavedData != null && oldSavedData.encryptedData.contentEquals(data.data)
} else false
val currentItem = if (oldItem == null) {
val baseItem = CryptContainerMetadata(
cryptContainerId = 0,
deviceId = deviceId,
categoryId = categoryId,
type = type,
serverVersion = data.version,
currentGeneration = 0,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
currentGenerationKey = null,
nextCounter = 0,
status = CryptContainerMetadata.ProcessingStatus.MissingKey
)
val cryptContainerId = database.cryptContainer().insertMetadata(baseItem)
baseItem.copy(cryptContainerId = cryptContainerId)
} else oldItem.copy(serverVersion = data.version)
if (deviceId == database.config().getOwnDeviceIdSync()) {
database.cryptContainer().updateMetadata(currentItem)
return Result(didCreateKeyRequests = false)
}
if (!isUnmodified) {
CryptContainerData(
cryptContainerId = currentItem.cryptContainerId,
encryptedData = data.data
).also { item ->
if (oldItem == null) database.cryptContainer().insertData(item)
else database.cryptContainer().updateData(item)
}
}
val header = try {
CryptContainer.Header.read(data.data)
} catch (ex: CryptException.InvalidContainer) {
null
}
val updatedMetadata = if (isUnmodified) {
currentItem
} else if (header == null) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)
} else if (header.generation < currentItem.currentGeneration) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected)
} else if (header.generation > currentItem.currentGeneration) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey)
} else if (header.counter < currentItem.nextCounter) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected)
} else if (currentItem.currentGenerationKey == null) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey)
} else {
try {
CryptContainer.decrypt(currentItem.currentGenerationKey, data.data)
currentItem.copy(
status = CryptContainerMetadata.ProcessingStatus.Unprocessed,
nextCounter = header.counter + 1
)
} catch (ex: CryptException.WrongKey) {
currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)
}
}
database.cryptContainer().updateMetadata(updatedMetadata)
if (updatedMetadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey && header != null) {
if (database.cryptContainerKeyRequest().byCryptContainerId(currentItem.cryptContainerId) == null) {
val signingKey = DeviceSigningKey.getPublicAndPrivateKeySync(database)!!.let { Curve25519.getPrivateKey(it) }
val requestKeyPair = Curve25519.generateKeyPair()
val sequenceNumber = database.config().getNextSigningSequenceNumberAndIncrementIt()
ApplyActionUtil.addAppLogicActionToDatabaseSync(
SendKeyRequestAction(
deviceSequenceNumber = sequenceNumber,
deviceId = deviceId,
categoryId = categoryId,
type = type,
tempKey = Curve25519.getPublicKey(requestKeyPair),
signature = Curve25519.sign(
signingKey,
KeyRequestSignedData(
deviceSequenceNumber = sequenceNumber,
deviceId = deviceId,
categoryId = categoryId,
type = type,
tempKey = Curve25519.getPublicKey(
requestKeyPair
)
).serialize()
)
), database
)
database.cryptContainerKeyRequest().insert(
CryptContainerPendingKeyRequest(
cryptContainerId = currentItem.cryptContainerId,
requestTimeCryptContainerGeneration = header.generation,
requestSequenceId = sequenceNumber,
requestKey = requestKeyPair
)
)
return Result(didCreateKeyRequests = true)
}
}
return Result(didCreateKeyRequests = false)
}
data class Result (val didCreateKeyRequests: Boolean) {
fun or(other: Result) = Result(this.didCreateKeyRequests or other.didCreateKeyRequests)
}
}

View file

@ -0,0 +1,56 @@
/*
* 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.logic.crypto
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.data.Database
import io.timelimit.android.logic.crypto.decrypt.DecryptProcessor
import io.timelimit.android.sync.network.ServerKeyRequest
import io.timelimit.android.sync.network.ServerKeyResponse
object CryptoSyncLogic {
suspend fun postPullHook(
database: Database,
keyRequests: List<ServerKeyRequest>,
keyResponses: List<ServerKeyResponse>
): Result {
return Threads.database.executeAndWait {
val privateKeyAndPublicKey = DeviceSigningKey.getPublicAndPrivateKeySync(database, uploadIfMissingAtServer = true)
?: return@executeAndWait Result(didSendReplies = false)
val keyRequestResult = KeyRequestProcessor.process(
keyRequests = keyRequests,
database = database,
privateKeyAndPublicKey = privateKeyAndPublicKey
)
val keyResponseResult = KeyResponseProcessor.process(
keyResponses = keyResponses,
database = database,
privateKeyAndPublicKey = privateKeyAndPublicKey
)
DecryptProcessor.handleEncryptedApps(database)
Result(
didSendReplies = keyRequestResult.didSendReplies or keyResponseResult.gotNewKeys
)
}
}
data class Result(val didSendReplies: Boolean)
}

View file

@ -0,0 +1,50 @@
/*
* 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.logic.crypto
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.sync.actions.UploadDevicePublicKeyAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
object DeviceSigningKey {
fun getPublicAndPrivateKeySync(database: Database, uploadIfMissingAtServer: Boolean = false): ByteArray? = database.runInTransaction {
if (database.config().getServerApiLevelSync() < 4) return@runInTransaction null
val oldData = database.config().getSigningKeySync()
if (oldData != null) {
if (database.deviceKey().getSync(database.config().getOwnDeviceIdSync()!!) == null && uploadIfMissingAtServer) {
ApplyActionUtil.addAppLogicActionToDatabaseSync(
UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(oldData)),
database
)
}
return@runInTransaction oldData
}
val newData = Curve25519.generateKeyPair()
database.config().setSigningKeySync(newData)
ApplyActionUtil.addAppLogicActionToDatabaseSync(
UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(newData)),
database
)
return@runInTransaction newData
}
}

View file

@ -0,0 +1,184 @@
/*
* 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.logic.crypto
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.sync.actions.ReplyToKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerKeyRequest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object KeyRequestProcessor {
private const val LOG_TAG = "KeyRequestProcessor"
fun process(
keyRequests: List<ServerKeyRequest>,
database: Database,
privateKeyAndPublicKey: ByteArray
): Result {
var didSendReplies = false
if (keyRequests.isNotEmpty()) {
val lastServerSequence = database.config().getLastServerKeyRequestSequenceSync()
var newServerSequence: Long? = null
for (request in keyRequests) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got key request: $request")
}
if (lastServerSequence != null && request.serverRequestSequenceNumber <= lastServerSequence) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because server sequence number repeated")
}
continue
}
if (newServerSequence == null || newServerSequence < request.serverRequestSequenceNumber) {
newServerSequence = request.serverRequestSequenceNumber
}
val deviceKey = database.deviceKey().getSync(request.senderDeviceId)
if (deviceKey == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no device key is known")
}
continue
}
if (request.senderSequenceNumber < deviceKey.nextSequenceNumber) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because client sequence number repeated")
}
continue
}
if (deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE || request.signature.size != Curve25519.SIGNATURE_SIZE) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size")
}
continue
}
val requestSignedData = KeyRequestSignedData(
deviceSequenceNumber = request.senderSequenceNumber,
deviceId = request.deviceId,
categoryId = request.categoryId,
type = request.type,
tempKey = request.tempKey
)
if (
!Curve25519.validateSignature(
deviceKey.publicKey,
requestSignedData.serialize(),
request.signature
)
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the signature is invalid")
}
continue
}
database.deviceKey().update(deviceKey.copy(nextSequenceNumber = request.senderSequenceNumber + 1))
val metadata = if (request.deviceId != null) {
database.cryptContainer().getCryptoMetadataSyncByDeviceId(request.deviceId, request.type)
} else if (request.categoryId != null) {
database.cryptContainer().getCryptoMetadataSyncByCategoryId(request.categoryId, request.type)
} else {
database.cryptContainer().getCryptoMetadataSyncByType(request.type)
}
if (
metadata?.currentGenerationKey == null ||
metadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the key is unknown")
}
continue
}
if (metadata.currentGenerationKey.size != CryptContainer.KEY_SIZE) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the key has the wrong size")
}
continue
}
val selfTempKey = Curve25519.generateKeyPair()
val sharedSecret = Curve25519.sharedSecret(
publicKey = request.tempKey,
privateKey = Curve25519.getPrivateKey(selfTempKey)
)
val encryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let {
// this encrypt just a single block and the integrity is checked using async crypto
// actually, xor could be enough for this case
it.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES"))
it.doFinal(metadata.currentGenerationKey)
}
ApplyActionUtil.addAppLogicActionToDatabaseSync(
action = ReplyToKeyRequestAction(
requestServerSequenceNumber = request.serverRequestSequenceNumber,
tempKey = Curve25519.getPublicKey(selfTempKey),
encryptedKey = encryptedKey,
signature = Curve25519.sign(
privateKey = Curve25519.getPrivateKey(privateKeyAndPublicKey),
message = KeyResponseSignedData(
request = requestSignedData,
senderDevicePublicKey = deviceKey.publicKey,
tempKey = Curve25519.getPublicKey(selfTempKey),
encryptedKey = encryptedKey
).serialize()
)
),
database = database
)
didSendReplies = true
}
newServerSequence?.let {
database.config().setLastServerKeyRequestSequenceSync(it)
}
}
return Result(
didSendReplies = didSendReplies
)
}
data class Result(val didSendReplies: Boolean)
}

View file

@ -0,0 +1,83 @@
/*
* 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.logic.crypto
import okio.Buffer
data class KeyRequestSignedData (
val deviceSequenceNumber: Long,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val tempKey: ByteArray
) {
init {
if (tempKey.size != 32) {
throw IllegalArgumentException()
}
}
fun serialize(): ByteArray = Buffer().also { buffer ->
fun writeBool(value: Boolean) = buffer.writeByte(when (value) {
false -> 0
true -> 1
})
fun writeString(value: String) = value.toByteArray(Charsets.UTF_8).also {
buffer.writeInt(it.size)
buffer.write(it)
}
fun writeOptionalString(value: String?) {
if (value == null) {
writeBool(false)
} else {
writeBool(true)
writeString(value)
}
}
writeString("KeyRequestSignedData")
buffer.writeLong(deviceSequenceNumber)
writeBool(deviceId != null)
writeOptionalString(deviceId)
writeOptionalString(categoryId)
buffer.writeInt(type)
buffer.write(tempKey) // fixed size => no length prefix required
}.readByteArray()
}
data class KeyResponseSignedData (
val request: KeyRequestSignedData,
val senderDevicePublicKey: ByteArray,
val tempKey: ByteArray,
val encryptedKey: ByteArray
) {
fun serialize(): ByteArray = Buffer().also { buffer ->
fun writeByteArray(array: ByteArray) {
buffer.writeInt(array.size)
buffer.write(array)
}
fun writeString(value: String) = writeByteArray(value.toByteArray(Charsets.UTF_8))
writeString("KeyResponseSignedData")
writeByteArray(senderDevicePublicKey)
writeByteArray(tempKey)
writeByteArray(encryptedKey)
buffer.write(request.serialize())
}.readByteArray()
}

View file

@ -0,0 +1,216 @@
/*
* 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.logic.crypto
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.CryptContainerKeyResult
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.sync.actions.FinishKeyRequestAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ServerKeyResponse
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object KeyResponseProcessor {
private const val LOG_TAG = "KeyResponseProcessor"
fun process(
keyResponses: List<ServerKeyResponse>,
database: Database,
privateKeyAndPublicKey: ByteArray
): Result {
var gotNewKeys = false
if (keyResponses.isNotEmpty()) {
val lastServerSequence = database.config().getLastServerKeyResponseSequenceSync()
var newServerSequence: Long? = null
for (response in keyResponses) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got key response: $response")
}
if (lastServerSequence != null && response.serverResponseSequenceNumber <= lastServerSequence) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because server sequence number repeated")
}
continue
}
if (newServerSequence == null || newServerSequence < response.serverResponseSequenceNumber) {
newServerSequence = response.serverResponseSequenceNumber
}
val deviceKey = database.deviceKey().getSync(response.senderDeviceId)
if (deviceKey == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no device key is known")
}
continue
}
if (
deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE ||
response.tempKey.size != Curve25519.PUBLIC_KEY_SIZE ||
response.signature.size != Curve25519.SIGNATURE_SIZE ||
response.encryptedKey.size != CryptContainer.KEY_SIZE
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size")
}
continue
}
val requestMeta = database.cryptContainerKeyRequest().byRequestId(response.requestSequenceId)
if (requestMeta == null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because no request was found")
}
continue
}
val cryptContainerMeta = database.cryptContainer().getCryptoMetadataSyncByContainerId(requestMeta.cryptContainerId)!!
if (
database.cryptContainerKeyResult().countResultItems(
requestSequenceNumber = requestMeta.requestSequenceId,
deviceId = response.senderDeviceId
) > 0
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because this device sent already an invalid reply")
}
continue
}
val requestSignedData = KeyRequestSignedData(
deviceSequenceNumber = requestMeta.requestSequenceId,
deviceId = cryptContainerMeta.deviceId,
categoryId = cryptContainerMeta.categoryId,
type = cryptContainerMeta.type,
tempKey = Curve25519.getPublicKey(requestMeta.requestKey)
)
val responseSignedData = KeyResponseSignedData(
request = requestSignedData,
senderDevicePublicKey = Curve25519.getPublicKey(privateKeyAndPublicKey),
encryptedKey = response.encryptedKey,
tempKey = response.tempKey
)
if (
!Curve25519.validateSignature(
deviceKey.publicKey,
responseSignedData.serialize(),
response.signature
)
) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "ignore because the signature is invalid")
}
continue
}
val sharedSecret = Curve25519.sharedSecret(
publicKey = response.tempKey,
privateKey = Curve25519.getPrivateKey(requestMeta.requestKey)
)
val decryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let {
// this encrypt just a single block and the integrity is checked using async crypto
// actually, xor could be enough for this case
it.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES"))
it.doFinal(response.encryptedKey)
}
val encryptedData = database.cryptContainer().getData(cryptContainerMeta.cryptContainerId)!!
val encryptedDataHeader = try {
CryptContainer.Header.read(encryptedData.encryptedData)
} catch (ex: CryptException.InvalidContainer) {
null
}
val isKeyValid = try {
CryptContainer.decrypt(decryptedKey, encryptedData.encryptedData)
true
} catch (ex: CryptException.WrongKey) {
false
}
if (isKeyValid && encryptedDataHeader != null) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got valid key")
}
gotNewKeys = true
ApplyActionUtil.addAppLogicActionToDatabaseSync(
action = FinishKeyRequestAction(deviceSequenceNumber = requestMeta.requestSequenceId),
database = database
)
database.cryptContainerKeyRequest().delete(requestMeta)
database.cryptContainer().updateMetadata(
cryptContainerMeta.copy(
currentGeneration = encryptedDataHeader.generation,
currentGenerationFirstTimestamp = System.currentTimeMillis(),
nextCounter = encryptedDataHeader.counter + 1,
currentGenerationKey = decryptedKey,
status = CryptContainerMetadata.ProcessingStatus.Unprocessed
)
)
} else {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got invalid key")
}
database.cryptContainerKeyResult().insert(
CryptContainerKeyResult(
requestSequenceId = requestMeta.requestSequenceId,
deviceId = response.senderDeviceId,
status = CryptContainerKeyResult.Status.InvalidKey
)
)
}
}
newServerSequence?.let {
database.config().setLastServerKeyResponseSequenceSync(it)
}
}
return Result(gotNewKeys = gotNewKeys)
}
data class Result(
val gotNewKeys: Boolean
)
}

View file

@ -0,0 +1,184 @@
/*
* 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.logic.crypto.decrypt
import android.util.Log
import io.timelimit.android.BuildConfig
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.crypto.CryptException
import io.timelimit.android.data.Database
import io.timelimit.android.data.dao.CryptContainerDao
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.CryptContainerMetadata
import io.timelimit.android.proto.decodeInflated
import io.timelimit.android.proto.toDb
import io.timelimit.proto.applist.InstalledAppsProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
import java.io.IOException
object DecryptProcessor {
private const val LOG_TAG = "DecryptProcessor"
fun handleEncryptedApps(database: Database) {
val unprocessed = database.cryptContainer().getMetadataByProcessingStatus(CryptContainerMetadata.ProcessingStatus.Unprocessed)
val finishedDeviceIds = mutableSetOf<String>()
for (metadata in unprocessed) {
if (
metadata.type == CryptContainerMetadata.TYPE_APP_LIST_BASE ||
metadata.type == CryptContainerMetadata.TYPE_APP_LIST_DIFF
) {
if (
metadata.deviceId == null ||
finishedDeviceIds.contains(metadata.deviceId) ||
metadata.deviceId == database.config().getOwnDeviceIdSync()
) continue
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "found data for ${metadata.deviceId}")
}
finishedDeviceIds.add(metadata.deviceId)
val baseData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) ?: continue
val diffData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_DIFF) ?: continue
if (!(isReadyForProcessing(baseData) && isReadyForProcessing(diffData))) continue
val (baseDecrypted, baseHeader) = try {
CryptContainer.decrypt(
baseData.metadata.currentGenerationKey ?: continue,
baseData.encryptedData
) to CryptContainer.Header.read(baseData.encryptedData)
} catch (ex: CryptException) {
database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage))
continue
}
val diffDecrypted = try {
CryptContainer.decrypt(
diffData.metadata.currentGenerationKey ?: continue,
diffData.encryptedData
)
} catch (ex: CryptException) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage))
continue
}
val baseContent = try {
InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted)
} catch (ex: IOException) {
database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
val diffContent = try {
SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted)
} catch (ex: IOException) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
if (
diffContent.base_generation != baseHeader.generation ||
diffContent.base_counter != baseHeader.counter
) {
database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage))
continue
}
database.app().deleteAllAppsByDeviceId(metadata.deviceId)
database.appActivity().deleteAppActivitiesByDeviceIds(listOf(metadata.deviceId))
database.app().addAppsSync(
baseContent.apps.map {
App(
deviceId = metadata.deviceId,
packageName = it.package_name,
title = it.title,
isLaunchable = it.is_launchable,
recommendation = it.recommendation.toDb()
)
}
)
database.appActivity().addAppActivitiesSync(
baseContent.activities.map {
AppActivity(
deviceId = metadata.deviceId,
appPackageName = it.package_name,
activityClassName = it.class_name,
title = it.title
)
}
)
database.app().removeAppsByDeviceIdAndPackageNamesSync(
metadata.deviceId,
diffContent.apps?.removed_packages ?: emptyList()
)
diffContent.apps?.removed_activities?.forEach {
database.appActivity().deleteAppActivitiesSync(
deviceId = metadata.deviceId,
packageName = it.package_name,
activities = listOf(it.class_name)
)
}
database.app().addAppsSync(
diffContent.apps?.added?.apps?.map {
App(
deviceId = metadata.deviceId,
packageName = it.package_name,
title = it.title,
isLaunchable = it.is_launchable,
recommendation = it.recommendation.toDb()
)
} ?: emptyList()
)
database.appActivity().addAppActivitiesSync(
diffContent.apps?.added?.activities?.map {
AppActivity(
deviceId = metadata.deviceId,
appPackageName = it.package_name,
activityClassName = it.class_name,
title = it.title
)
} ?: emptyList()
)
database.cryptContainer().updateMetadata(listOf(
baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished),
diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished)
))
}
}
}
private fun isReadyForProcessing(value: CryptContainerDao.MetadataAndContent) = when (value.metadata.status) {
CryptContainerMetadata.ProcessingStatus.Unprocessed -> true
CryptContainerMetadata.ProcessingStatus.Finished -> true
else -> false
}
}

View file

@ -0,0 +1,75 @@
/*
* 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.proto
import io.timelimit.android.crypto.CryptContainer
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.AppRecommendation
import io.timelimit.android.sync.actions.AppActivityItem
import io.timelimit.android.sync.actions.InstalledApp
import io.timelimit.proto.applist.InstalledAppActivityProto
import io.timelimit.proto.applist.InstalledAppProto
import io.timelimit.proto.applist.InstalledAppsDifferenceProto
import io.timelimit.proto.applist.SavedAppsDifferenceProto
fun InstalledAppProto.Recommendation.toDb(): AppRecommendation = when (this) {
InstalledAppProto.Recommendation.NONE -> AppRecommendation.None
InstalledAppProto.Recommendation.WHITELIST -> AppRecommendation.Whitelist
InstalledAppProto.Recommendation.BLACKLIST -> AppRecommendation.Blacklist
}
fun AppRecommendation.toProto(): InstalledAppProto.Recommendation = when (this) {
AppRecommendation.None -> InstalledAppProto.Recommendation.NONE
AppRecommendation.Whitelist -> InstalledAppProto.Recommendation.WHITELIST
AppRecommendation.Blacklist -> InstalledAppProto.Recommendation.BLACKLIST
}
fun InstalledAppProto.toInstalledApp(): InstalledApp = InstalledApp(
packageName = this.package_name,
title = this.title,
recommendation = this.recommendation.toDb(),
isLaunchable = this.is_launchable
)
fun App.toProto(): InstalledAppProto = InstalledAppProto(
package_name = this.packageName,
title = this.title,
is_launchable = this.isLaunchable,
recommendation = this.recommendation.toProto()
)
fun InstalledAppActivityProto.toAppActivityItem(): AppActivityItem = AppActivityItem(
packageName = this.package_name,
className = this.class_name,
title = this.title
)
fun AppActivity.toProto(): InstalledAppActivityProto = InstalledAppActivityProto(
package_name = this.appPackageName,
class_name = this.activityClassName,
title = this.title
)
fun SavedAppsDifferenceProto.Companion.build(header: CryptContainer.Header, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto =
SavedAppsDifferenceProto(
base_generation = header.generation,
base_counter = header.counter,
apps = diff
)
fun SavedAppsDifferenceProto.Companion.build(encryptedBaseData: ByteArray, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto =
build(CryptContainer.Header.read(encryptedBaseData), diff)

View file

@ -0,0 +1,32 @@
/*
* 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.proto
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import okio.Buffer
import okio.use
import java.io.ByteArrayInputStream
import java.util.zip.DeflaterOutputStream
import java.util.zip.InflaterInputStream
fun <A : Message<A, B>, B : Message.Builder<A, B>> Message<A, B>.encodeDeflated(): ByteArray = Buffer().also { buffer ->
DeflaterOutputStream(buffer.outputStream()).use { this.encode(it) }
}.readByteArray()
fun <T> ProtoAdapter<T>.decodeInflated(input: ByteArray): T = InflaterInputStream(ByteArrayInputStream(input)).use {
this.decode(it)
}

View file

@ -21,21 +21,21 @@ import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.integration.platform.PlatformIntegration import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.sync.actions.DatabaseValidation import io.timelimit.android.logic.crypto.CryptDataHandler
import io.timelimit.android.sync.actions.DeleteCategoryAction import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.RemoveUserAction
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher
import io.timelimit.android.sync.network.ServerCryptContainer
import io.timelimit.android.sync.network.ServerDataStatus import io.timelimit.android.sync.network.ServerDataStatus
object ApplyServerDataStatus { object ApplyServerDataStatus {
suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) { suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result {
Threads.database.executeAndWait { return Threads.database.executeAndWait {
applyServerDataStatusSync(status, database, platformIntegration) applyServerDataStatusSync(status, database, platformIntegration)
} }
} }
fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) { fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result {
database.runInTransaction { return database.runInTransaction {
// this would override some local data which was not sent yet // this would override some local data which was not sent yet
// so it's better to cancel in this case (or, more complicated, // so it's better to cancel in this case (or, more complicated,
// apply the delta which was not sent locally) // apply the delta which was not sent locally)
@ -53,6 +53,8 @@ object ApplyServerDataStatus {
database.config().setServerApiLevelSync(status.apiLevel) database.config().setServerApiLevelSync(status.apiLevel)
} }
var didCreateNewActions = false
run { run {
val newUserList = status.newUserList val newUserList = status.newUserList
@ -123,9 +125,10 @@ object ApplyServerDataStatus {
} }
} }
run { val newDeviceTitles = run {
// apply new device list // apply new device list
val newDeviceList = status.newDeviceList val newDeviceList = status.newDeviceList
val newDeviceTitles = mutableListOf<String>()
if (newDeviceList != null) { if (newDeviceList != null) {
val oldDeviceList = database.device().getAllDevicesSync() val oldDeviceList = database.device().getAllDevicesSync()
@ -191,6 +194,8 @@ object ApplyServerDataStatus {
qOrLater = newDevice.qOrLater, qOrLater = newDevice.qOrLater,
manipulationFlags = newDevice.manipulationFlags manipulationFlags = newDevice.manipulationFlags
)) ))
newDeviceTitles.add(newDevice.name)
} else { } else {
// eventually update old entry // eventually update old entry
@ -239,44 +244,85 @@ object ApplyServerDataStatus {
} }
} }
} }
if (newDevice.publicKey != null) {
val entry = database.deviceKey().getSync(newDevice.deviceId)
if (entry == null) {
database.deviceKey().insert(DevicePublicKey(
deviceId = newDevice.deviceId,
publicKey = newDevice.publicKey,
nextSequenceNumber = 0
))
}
}
} }
} }
database.config().setDeviceListVersionSync(newDeviceList.version) database.config().setDeviceListVersionSync(newDeviceList.version)
} }
newDeviceTitles.toList()
} }
run { run {
status.newInstalledApps.forEach { status.updatedExtendedDeviceData.forEach { item ->
item -> fun handle(data: ServerCryptContainer, type: Int) {
val result = CryptDataHandler.process(
database = database,
data = data,
type = type,
categoryId = null,
deviceId = item.deviceId
)
if (result.didCreateKeyRequests) {
didCreateNewActions = true
}
}
item.appsBase?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_BASE) }
item.appsDiff?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_DIFF) }
}
}
run {
val disableLegacySync = database.config().isExperimentalFlagsSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING)
for (item in status.newInstalledApps) {
DatabaseValidation.assertDeviceExists(database, item.deviceId) DatabaseValidation.assertDeviceExists(database, item.deviceId)
run { if (
// apply apps database.cryptContainer().getCryptoMetadataSyncByDeviceId(item.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) == null ||
database.app().deleteAllAppsByDeviceId(item.deviceId) (item.deviceId == database.config().getOwnDeviceIdSync() && !disableLegacySync)
database.app().addAppsSync(item.apps.map { ) {
App( run {
// apply apps
database.app().deleteAllAppsByDeviceId(item.deviceId)
database.app().addAppsSync(item.apps.map {
App(
deviceId = item.deviceId, deviceId = item.deviceId,
packageName = it.packageName, packageName = it.packageName,
title = it.title, title = it.title,
isLaunchable = it.isLaunchable, isLaunchable = it.isLaunchable,
recommendation = it.recommendation recommendation = it.recommendation
) )
}) })
} }
run { run {
// apply activities // apply activities
database.appActivity().deleteAppActivitiesByDeviceIds(listOf(item.deviceId)) database.appActivity()
database.appActivity().addAppActivitiesSync(item.activities.map { .deleteAppActivitiesByDeviceIds(listOf(item.deviceId))
AppActivity( database.appActivity().addAppActivitiesSync(item.activities.map {
AppActivity(
deviceId = item.deviceId, deviceId = item.deviceId,
appPackageName = it.packageName, appPackageName = it.packageName,
activityClassName = it.className, activityClassName = it.className,
title = it.title title = it.title
) )
}) })
}
} }
run { run {
@ -565,8 +611,24 @@ object ApplyServerDataStatus {
} }
} }
} }
Result(
newDeviceTitles = newDeviceTitles,
didCreateNewActions = didCreateNewActions
)
}
}
fun postNotifications(result: Result, platformIntegration: PlatformIntegration) {
result.newDeviceTitles.forEach { deviceTitle ->
platformIntegration.showNewDeviceNotification(deviceTitle)
} }
} }
class PendingSyncActionException: RuntimeException() class PendingSyncActionException: RuntimeException()
data class Result (
val newDeviceTitles: List<String>,
val didCreateNewActions: Boolean
)
} }

View file

@ -27,6 +27,7 @@ import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.ServerLogic import io.timelimit.android.logic.ServerLogic
import io.timelimit.android.logic.crypto.CryptoSyncLogic
import io.timelimit.android.sync.actions.apply.UploadActionsUtil import io.timelimit.android.sync.actions.apply.UploadActionsUtil
import io.timelimit.android.sync.network.ClientDataStatus import io.timelimit.android.sync.network.ClientDataStatus
import io.timelimit.android.sync.network.api.UnauthorizedHttpError import io.timelimit.android.sync.network.api.UnauthorizedHttpError
@ -246,9 +247,19 @@ class SyncUtil (private val logic: AppLogic) {
} }
private suspend fun pullStatus(server: ServerLogic.ServerConfig) { private suspend fun pullStatus(server: ServerLogic.ServerConfig) {
val currentStatus = ClientDataStatus.getClientDataStatusAsync(logic.database) val currentStatus = Threads.database.executeAndWait { ClientDataStatus.getClientDataStatusSync(logic.database) }
val serverResponse = server.api.pullChanges(server.deviceAuthToken, currentStatus) val serverResponse = server.api.pullChanges(server.deviceAuthToken, currentStatus)
ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration) val applyResult = ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration)
ApplyServerDataStatus.postNotifications(applyResult, logic.platformIntegration)
val cryptoResult = CryptoSyncLogic.postPullHook(logic.database, serverResponse.pendingKeyRequests, serverResponse.keyResponses)
if (applyResult.didCreateNewActions or cryptoResult.didSendReplies) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "request next sync by crypto handling")
}
requestImportantSync()
}
} }
private suspend fun wipeCacheIfUpdated() { private suspend fun wipeCacheIfUpdated() {

View file

@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.base64
import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.network.ParentPassword import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.validation.ListValidation import io.timelimit.android.sync.validation.ListValidation
@ -468,6 +469,144 @@ data class UpdateAppActivitiesAction(
writer.endObject() writer.endObject()
} }
} }
data class UpdateInstalledAppsAction (
val base: ByteArray?,
val diff: ByteArray?,
val wipe: Boolean
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "UPDATE_INSTALLED_APPS"
private const val BASE = "b"
private const val DIFF = "d"
private const val WIPE = "w"
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
base?.let { writer.name(BASE).value(it.base64()) }
diff?.let { writer.name(DIFF).value(it.base64()) }
writer.name(WIPE).value(wipe)
writer.endObject()
}
}
data class UploadDevicePublicKeyAction (val publicKey: ByteArray): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "UPLOAD_DEVICE_PUBLIC_KEY"
private const val KEY = "key"
}
init {
if (publicKey.size != 32) throw IllegalArgumentException()
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(KEY).value(publicKey.base64())
writer.endObject()
}
}
data class SendKeyRequestAction(
val deviceSequenceNumber: Long,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val tempKey: ByteArray,
val signature: ByteArray
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "SEND_KEY_REQUEST"
private const val SEQ_NUM = "dsn"
private const val DEVICE_ID = "deviceId"
private const val CATEGORY_ID = "categoryId"
private const val DATA_TYPE = "dataType"
private const val TEMP_KEY = "tempKey"
private const val SIGNATURE = "signature"
}
init {
if (tempKey.size != 32 || signature.size != 64) {
throw IllegalArgumentException()
}
if (deviceId != null && categoryId != null) {
throw IllegalArgumentException()
}
deviceId?.also { IdGenerator.assertIdValid(it) }
categoryId?.also { IdGenerator.assertIdValid(it) }
if (!CryptContainerMetadata.isTypeValid(type)) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(SEQ_NUM).value(deviceSequenceNumber)
deviceId?.let { writer.name(DEVICE_ID).value(it) }
categoryId?.let { writer.name(CATEGORY_ID).value(it) }
writer.name(DATA_TYPE).value(type)
writer.name(TEMP_KEY).value(tempKey.base64())
writer.name(SIGNATURE).value(signature.base64())
writer.endObject()
}
}
data class FinishKeyRequestAction(val deviceSequenceNumber: Long): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "FINISH_KEY_REQUEST"
private const val SEQ_NUM = "dsn"
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(SEQ_NUM).value(deviceSequenceNumber)
writer.endObject()
}
}
data class ReplyToKeyRequestAction(
val requestServerSequenceNumber: Long,
val tempKey: ByteArray,
val encryptedKey: ByteArray,
val signature: ByteArray
): AppLogicAction() {
companion object {
private const val TYPE_VALUE = "REPLY_TO_KEY_REQUEST"
private const val REQUEST_SEQUENCE_NUMBER = "rsn"
private const val TEMP_KEY = "tempKey"
private const val ENCRYPTED_KEY = "encryptedKey"
private const val SIGNATURE = "signature"
}
init {
if (tempKey.size != 32 || encryptedKey.size != 16 || signature.size != 64) {
throw IllegalArgumentException()
}
}
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.name(REQUEST_SEQUENCE_NUMBER).value(requestServerSequenceNumber)
writer.name(TEMP_KEY).value(tempKey.base64())
writer.name(ENCRYPTED_KEY).value(encryptedKey.base64())
writer.name(SIGNATURE).value(signature.base64())
writer.endObject()
}
}
object SignOutAtDeviceAction: AppLogicAction() { object SignOutAtDeviceAction: AppLogicAction() {
const val TYPE_VALUE = "SIGN_OUT_AT_DEVICE" const val TYPE_VALUE = "SIGN_OUT_AT_DEVICE"

View file

@ -38,6 +38,19 @@ import java.io.StringWriter
object ApplyActionUtil { object ApplyActionUtil {
private const val LOG_TAG = "ApplyActionUtil" private const val LOG_TAG = "ApplyActionUtil"
fun addAppLogicActionToDatabaseSync(action: AppLogicAction, database: Database) = database.runInUnobservedTransaction {
database.pendingSyncAction().addSyncActionSync(
PendingSyncAction(
sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(),
scheduledForUpload = false,
type = PendingSyncActionType.AppLogic,
userId = "",
integrity = "",
encodedAction = SerializationUtil.serializeAction(action)
)
)
}
suspend fun applyAppLogicAction( suspend fun applyAppLogicAction(
action: AppLogicAction, action: AppLogicAction,
appLogic: AppLogic, appLogic: AppLogic,
@ -163,20 +176,7 @@ object ApplyActionUtil {
} }
} }
val serializedAction = StringWriter().apply { addAppLogicActionToDatabaseSync(action, database)
JsonWriter(this).apply {
action.serialize(this)
}
}.toString()
database.pendingSyncAction().addSyncActionSync(PendingSyncAction(
sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(),
encodedAction = serializedAction,
integrity = "",
scheduledForUpload = false,
type = PendingSyncActionType.AppLogic,
userId = ""
))
if (action is AddUsedTimeActionVersion2) { if (action is AddUsedTimeActionVersion2) {
syncUtil.requestVeryUnimportantSync() syncUtil.requestVeryUnimportantSync()

View file

@ -26,12 +26,15 @@ class UploadActionsUtil(private val database: Database, private val syncConflict
companion object { companion object {
private const val BATCH_SIZE = 25 private const val BATCH_SIZE = 25
fun deleteAllVersionNumbersSync(database: Database) { fun deleteAllVersionNumbersSync(database: Database, wipeCryptoRequests: Boolean = false) {
database.runInTransaction { database.runInTransaction {
database.config().setUserListVersionSync("") database.config().setUserListVersionSync("")
database.config().setDeviceListVersionSync("") database.config().setDeviceListVersionSync("")
database.device().deleteAllInstalledAppsVersions() database.device().deleteAllInstalledAppsVersions()
database.category().deleteAllCategoriesVersionNumbers() database.category().deleteAllCategoriesVersionNumbers()
database.cryptContainer().deleteAllServerVersionNumbers()
if (wipeCryptoRequests) { database.cryptContainerKeyRequest().deleteAll() }
} }
} }
} }

View file

@ -388,6 +388,11 @@ object LocalDatabaseAppLogicActionDispatcher {
database.childTasks().updateItemSync(task.copy(pendingRequest = true)) database.childTasks().updateItemSync(task.copy(pendingRequest = true))
} }
is UpdateInstalledAppsAction -> {/* nothing to do, this is only for the server */}
is UploadDevicePublicKeyAction -> {/* nothing to do, this is only for the server */}
is SendKeyRequestAction -> {/* nothing to do, this is only for the server */}
is FinishKeyRequestAction -> {/* nothing to do, this is only for the server */}
is ReplyToKeyRequestAction -> {/* nothing to do, this is only for the server */}
}.let { } }.let { }
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -17,16 +17,15 @@ package io.timelimit.android.sync.network
import android.util.JsonWriter import android.util.JsonWriter
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.waitForNonNullValue
import java.util.*
import kotlin.collections.HashMap
data class ClientDataStatus( data class ClientDataStatus(
val deviceListVersion: String, val deviceListVersion: String,
val installedAppsVersionsByDeviceId: Map<String, String>, val installedAppsVersionsByDeviceId: Map<String, String>,
val deviceDetailData: Map<String, DeviceDataStatus>,
val categories: Map<String, CategoryDataStatus>, val categories: Map<String, CategoryDataStatus>,
val userListVersion: String val userListVersion: String,
val lastKeyRequestServerSequence: Long?,
val lastKeyResponseServerSequence: Long?
) { ) {
companion object { companion object {
private const val DEVICES = "devices" private const val DEVICES = "devices"
@ -34,44 +33,59 @@ data class ClientDataStatus(
private const val CATEGORIES = "categories" private const val CATEGORIES = "categories"
private const val USERS = "users" private const val USERS = "users"
private const val CLIENT_LEVEL = "clientLevel" private const val CLIENT_LEVEL = "clientLevel"
private const val CLIENT_LEVEL_VALUE = 3 private const val DEVICES_DETAIL = "devicesDetail"
private const val LAST_KEY_REQUEST_SEQUENCE = "kri"
private const val LAST_KEY_RESPONSE_SEQUENCE = "kr"
private const val CLIENT_LEVEL_VALUE = 4
val empty = ClientDataStatus( val empty = ClientDataStatus(
deviceListVersion = "", deviceListVersion = "",
installedAppsVersionsByDeviceId = Collections.emptyMap(), installedAppsVersionsByDeviceId = emptyMap(),
categories = Collections.emptyMap(), deviceDetailData = emptyMap(),
userListVersion = "" categories = emptyMap(),
userListVersion = "",
lastKeyRequestServerSequence = null,
lastKeyResponseServerSequence = null
) )
suspend fun getClientDataStatusAsync(database: Database): ClientDataStatus { fun getClientDataStatusSync(database: Database): ClientDataStatus {
return ClientDataStatus( return database.runInUnobservedTransaction {
deviceListVersion = database.config().getDeviceListVersion().waitForNonNullValue(), ClientDataStatus(
installedAppsVersionsByDeviceId = database.device().getInstalledAppsVersions().map { deviceListVersion = database.config().getDeviceListVersionSync(),
val devicesWithAppVersions = it installedAppsVersionsByDeviceId = database.device()
val result = HashMap<String, String>() .getInstalledAppsVersionsSync()
.associateBy { it.deviceId }
.mapValues { it.value.installedAppsVersions },
deviceDetailData = if (database.config().getServerApiLevelSync() >= 4)
database.device().getDeviceDetailDataSync()
.associateBy { it.deviceId }
.mapValues {
val item = it.value
devicesWithAppVersions.forEach { result[it.deviceId] = it.installedAppsVersions } DeviceDataStatus(
appsBaseVersion = item.appBaseVersion,
appsDiffVersion = item.appDiffVersion
)
}
else emptyMap(),
categories = database.category().getCategoriesWithVersionNumbersSybc()
.associateBy { it.categoryId }
.mapValues {
val item = it.value
Collections.unmodifiableMap(result) CategoryDataStatus(
}.waitForNonNullValue(), baseVersion = item.baseVersion,
categories = database.category().getCategoriesWithVersionNumbers().map { assignedAppsVersion = item.assignedAppsVersion,
val categoriesWithVersions = it timeLimitRulesVersion = item.timeLimitRulesVersion,
val result = HashMap<String, CategoryDataStatus>() usedTimeItemsVersion = item.usedTimeItemsVersion,
taskListVersion = item.taskListVersion
categoriesWithVersions.forEach {
result[it.categoryId] = CategoryDataStatus(
baseVersion = it.baseVersion,
assignedAppsVersion = it.assignedAppsVersion,
timeLimitRulesVersion = it.timeLimitRulesVersion,
usedTimeItemsVersion = it.usedTimeItemsVersion,
taskListVersion = it.taskListVersion
) )
} },
userListVersion = database.config().getUserListVersionSync(),
Collections.unmodifiableMap(result) lastKeyRequestServerSequence = database.config().getLastServerKeyRequestSequenceSync(),
}.waitForNonNullValue(), lastKeyResponseServerSequence = database.config().getLastServerKeyResponseSequenceSync()
userListVersion = database.config().getUserListVersion().waitForNonNullValue() )
) }
} }
} }
@ -89,6 +103,16 @@ data class ClientDataStatus(
} }
writer.endObject() writer.endObject()
if (deviceDetailData.isNotEmpty()) {
writer.name(DEVICES_DETAIL)
writer.beginObject()
deviceDetailData.entries.forEach {
writer.name(it.key)
it.value.serialize(writer)
}
writer.endObject()
}
writer.name(CATEGORIES) writer.name(CATEGORIES)
writer.beginObject() writer.beginObject()
categories.entries.forEach { categories.entries.forEach {
@ -97,6 +121,9 @@ data class ClientDataStatus(
} }
writer.endObject() writer.endObject()
lastKeyRequestServerSequence?.let { writer.name(LAST_KEY_REQUEST_SEQUENCE).value(it) }
lastKeyResponseServerSequence?.let { writer.name(LAST_KEY_RESPONSE_SEQUENCE).value(it) }
writer.endObject() writer.endObject()
} }
} }
@ -126,6 +153,25 @@ data class CategoryDataStatus(
if (taskListVersion.isNotEmpty()) writer.name(TASK_LIST_VERSION).value(taskListVersion) if (taskListVersion.isNotEmpty()) writer.name(TASK_LIST_VERSION).value(taskListVersion)
writer.endObject()
}
}
data class DeviceDataStatus (
val appsBaseVersion: String?,
val appsDiffVersion: String?
) {
companion object {
private const val APPS_BASE_VERSION = "appsB"
private const val APPS_DIFF_VERSION = "appsD"
}
fun serialize(writer: JsonWriter) {
writer.beginObject()
appsBaseVersion?.let { writer.name(APPS_BASE_VERSION).value(it) }
appsDiffVersion?.let { writer.name(APPS_DIFF_VERSION).value(it) }
writer.endObject() writer.endObject()
} }
} }

View file

@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask
import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.customtypes.ImmutableBitmaskJson
import io.timelimit.android.data.model.* import io.timelimit.android.data.model.*
import io.timelimit.android.extensions.MinuteOfDay import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.extensions.parseBase64
import io.timelimit.android.extensions.parseList import io.timelimit.android.extensions.parseList
import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.*
import io.timelimit.android.sync.actions.AppActivityItem import io.timelimit.android.sync.actions.AppActivityItem
@ -34,6 +35,7 @@ import kotlin.collections.ArrayList
data class ServerDataStatus( data class ServerDataStatus(
val newDeviceList: ServerDeviceList?, val newDeviceList: ServerDeviceList?,
val updatedExtendedDeviceData: List<ServerExtendedDeviceData>,
val newInstalledApps: List<ServerInstalledAppsData>, val newInstalledApps: List<ServerInstalledAppsData>,
val removedCategories: List<String>, val removedCategories: List<String>,
val newCategoryBaseData: List<ServerUpdatedCategoryBaseData>, val newCategoryBaseData: List<ServerUpdatedCategoryBaseData>,
@ -42,12 +44,15 @@ data class ServerDataStatus(
val newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules>, val newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules>,
val newCategoryTasks: List<ServerUpdatedCategoryTasks>, val newCategoryTasks: List<ServerUpdatedCategoryTasks>,
val newUserList: ServerUserList?, val newUserList: ServerUserList?,
val pendingKeyRequests: List<ServerKeyRequest>,
val keyResponses: List<ServerKeyResponse>,
val fullVersionUntil: Long, val fullVersionUntil: Long,
val message: String?, val message: String?,
val apiLevel: Int val apiLevel: Int
) { ) {
companion object { companion object {
private const val NEW_DEVICE_LIST = "devices" private const val NEW_DEVICE_LIST = "devices"
private const val UPDATED_EXTENDED_DEVICE_DATA = "devices2"
private const val NEW_INSTALLED_APPS = "apps" private const val NEW_INSTALLED_APPS = "apps"
private const val REMOVED_CATEGORIES = "rmCategories" private const val REMOVED_CATEGORIES = "rmCategories"
private const val NEW_CATEGORIES_BASE_DATA = "categoryBase" private const val NEW_CATEGORIES_BASE_DATA = "categoryBase"
@ -56,12 +61,15 @@ data class ServerDataStatus(
private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules" private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules"
private const val NEW_CATEGORY_TASKS = "tasks" private const val NEW_CATEGORY_TASKS = "tasks"
private const val NEW_USER_LIST = "users" private const val NEW_USER_LIST = "users"
private const val PENDING_KEY_REQUESTS = "krq"
private const val KEY_RESPONSES = "kr"
private const val FULL_VERSION_UNTIL = "fullVersion" private const val FULL_VERSION_UNTIL = "fullVersion"
private const val MESSAGE = "message" private const val MESSAGE = "message"
private const val API_LEVEL = "apiLevel" private const val API_LEVEL = "apiLevel"
fun parse(reader: JsonReader): ServerDataStatus { fun parse(reader: JsonReader): ServerDataStatus {
var newDeviceList: ServerDeviceList? = null var newDeviceList: ServerDeviceList? = null
var updatedExtendedDeviceData: List<ServerExtendedDeviceData> = emptyList()
var newInstalledApps: List<ServerInstalledAppsData> = Collections.emptyList() var newInstalledApps: List<ServerInstalledAppsData> = Collections.emptyList()
var removedCategories: List<String> = Collections.emptyList() var removedCategories: List<String> = Collections.emptyList()
var newCategoryBaseData: List<ServerUpdatedCategoryBaseData> = Collections.emptyList() var newCategoryBaseData: List<ServerUpdatedCategoryBaseData> = Collections.emptyList()
@ -70,6 +78,8 @@ data class ServerDataStatus(
var newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules> = Collections.emptyList() var newCategoryTimeLimitRules: List<ServerUpdatedTimeLimitRules> = Collections.emptyList()
var newCategoryTasks: List<ServerUpdatedCategoryTasks> = emptyList() var newCategoryTasks: List<ServerUpdatedCategoryTasks> = emptyList()
var newUserList: ServerUserList? = null var newUserList: ServerUserList? = null
var pendingKeyRequests = emptyList<ServerKeyRequest>()
var keyResponses = emptyList<ServerKeyResponse>()
var fullVersionUntil: Long? = null var fullVersionUntil: Long? = null
var message: String? = null var message: String? = null
var apiLevel = 0 var apiLevel = 0
@ -78,6 +88,7 @@ data class ServerDataStatus(
while (reader.hasNext()) { while (reader.hasNext()) {
when(reader.nextName()) { when(reader.nextName()) {
NEW_DEVICE_LIST -> newDeviceList = ServerDeviceList.parse(reader) NEW_DEVICE_LIST -> newDeviceList = ServerDeviceList.parse(reader)
UPDATED_EXTENDED_DEVICE_DATA -> updatedExtendedDeviceData = parseJsonArray(reader, { ServerExtendedDeviceData.parse(reader) })
NEW_INSTALLED_APPS -> newInstalledApps = ServerInstalledAppsData.parseList(reader) NEW_INSTALLED_APPS -> newInstalledApps = ServerInstalledAppsData.parseList(reader)
REMOVED_CATEGORIES -> removedCategories = parseJsonStringArray(reader) REMOVED_CATEGORIES -> removedCategories = parseJsonStringArray(reader)
NEW_CATEGORIES_BASE_DATA -> newCategoryBaseData = ServerUpdatedCategoryBaseData.parseList(reader) NEW_CATEGORIES_BASE_DATA -> newCategoryBaseData = ServerUpdatedCategoryBaseData.parseList(reader)
@ -86,6 +97,8 @@ data class ServerDataStatus(
NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader) NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader)
NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader) NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader)
NEW_USER_LIST -> newUserList = ServerUserList.parse(reader) NEW_USER_LIST -> newUserList = ServerUserList.parse(reader)
PENDING_KEY_REQUESTS -> pendingKeyRequests = ServerKeyRequest.parseList(reader)
KEY_RESPONSES -> keyResponses = ServerKeyResponse.parseList(reader)
FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong() FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong()
MESSAGE -> message = reader.nextString() MESSAGE -> message = reader.nextString()
API_LEVEL -> apiLevel = reader.nextInt() API_LEVEL -> apiLevel = reader.nextInt()
@ -95,18 +108,21 @@ data class ServerDataStatus(
reader.endObject() reader.endObject()
return ServerDataStatus( return ServerDataStatus(
newDeviceList = newDeviceList, newDeviceList = newDeviceList,
newInstalledApps = newInstalledApps, updatedExtendedDeviceData = updatedExtendedDeviceData,
removedCategories = removedCategories, newInstalledApps = newInstalledApps,
newCategoryBaseData = newCategoryBaseData, removedCategories = removedCategories,
newCategoryAssignedApps = newCategoryAssignedApps, newCategoryBaseData = newCategoryBaseData,
newCategoryUsedTimes = newCategoryUsedTimes, newCategoryAssignedApps = newCategoryAssignedApps,
newCategoryTimeLimitRules = newCategoryTimeLimitRules, newCategoryUsedTimes = newCategoryUsedTimes,
newCategoryTasks = newCategoryTasks, newCategoryTimeLimitRules = newCategoryTimeLimitRules,
newUserList = newUserList, newCategoryTasks = newCategoryTasks,
fullVersionUntil = fullVersionUntil!!, newUserList = newUserList,
message = message, pendingKeyRequests = pendingKeyRequests,
apiLevel = apiLevel keyResponses = keyResponses,
fullVersionUntil = fullVersionUntil!!,
message = message,
apiLevel = apiLevel
) )
} }
} }
@ -299,7 +315,8 @@ data class ServerDeviceData(
val wasAccessibilityServiceEnabled: Boolean, val wasAccessibilityServiceEnabled: Boolean,
val enableActivityLevelBlocking: Boolean, val enableActivityLevelBlocking: Boolean,
val qOrLater: Boolean, val qOrLater: Boolean,
val manipulationFlags: Long val manipulationFlags: Long,
val publicKey: ByteArray?
) { ) {
companion object { companion object {
private const val DEVICE_ID = "deviceId" private const val DEVICE_ID = "deviceId"
@ -333,6 +350,7 @@ data class ServerDeviceData(
private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "activityLevelBlocking" private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "activityLevelBlocking"
private const val Q_OR_LATER = "qOrLater" private const val Q_OR_LATER = "qOrLater"
private const val MANIPULATION_FLAGS = "mFlags" private const val MANIPULATION_FLAGS = "mFlags"
private const val PUBLIC_KEY = "pk"
fun parse(reader: JsonReader): ServerDeviceData { fun parse(reader: JsonReader): ServerDeviceData {
var deviceId: String? = null var deviceId: String? = null
@ -366,6 +384,7 @@ data class ServerDeviceData(
var enableActivityLevelBlocking = false var enableActivityLevelBlocking = false
var qOrLater = false var qOrLater = false
var manipulationFlags = 0L var manipulationFlags = 0L
var publicKey: ByteArray? = null
reader.beginObject() reader.beginObject()
while (reader.hasNext()) { while (reader.hasNext()) {
@ -401,6 +420,7 @@ data class ServerDeviceData(
ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean() ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean()
Q_OR_LATER -> qOrLater = reader.nextBoolean() Q_OR_LATER -> qOrLater = reader.nextBoolean()
MANIPULATION_FLAGS -> manipulationFlags = reader.nextLong() MANIPULATION_FLAGS -> manipulationFlags = reader.nextLong()
PUBLIC_KEY -> publicKey = reader.nextString().parseBase64()
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -437,7 +457,8 @@ data class ServerDeviceData(
wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled!!, wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled!!,
enableActivityLevelBlocking = enableActivityLevelBlocking, enableActivityLevelBlocking = enableActivityLevelBlocking,
qOrLater = qOrLater, qOrLater = qOrLater,
manipulationFlags = manipulationFlags manipulationFlags = manipulationFlags,
publicKey = publicKey
) )
} }
@ -1065,3 +1086,182 @@ data class ServerInstalledAppsData(
fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) } fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) }
} }
} }
data class ServerExtendedDeviceData(
val deviceId: String,
val appsBase: ServerCryptContainer?,
val appsDiff: ServerCryptContainer?
) {
companion object {
private const val DEVICE_ID = "deviceId"
private const val APPS_BASE = "appsBase"
private const val APPS_DIFF = "appsDiff"
fun parse(reader: JsonReader): ServerExtendedDeviceData {
var deviceId: String? = null
var appsBase: ServerCryptContainer? = null
var appsDiff: ServerCryptContainer? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
DEVICE_ID -> deviceId = reader.nextString()
APPS_BASE -> appsBase = ServerCryptContainer.parse(reader)
APPS_DIFF -> appsDiff = ServerCryptContainer.parse(reader)
else -> reader.skipValue()
}
}
reader.endObject()
return ServerExtendedDeviceData(
deviceId = deviceId!!,
appsBase = appsBase,
appsDiff = appsDiff
)
}
}
}
data class ServerCryptContainer(
val version: String,
val data: ByteArray
) {
companion object {
private const val VERSION = "version"
private const val DATA = "data"
fun parse(reader: JsonReader): ServerCryptContainer {
var version: String? = null
var data: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
VERSION -> version = reader.nextString()
DATA -> data = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerCryptContainer(
version = version!!,
data = data!!
)
}
}
}
data class ServerKeyRequest(
val serverRequestSequenceNumber: Long,
val senderDeviceId: String,
val senderSequenceNumber: Long,
val tempKey: ByteArray,
val deviceId: String?,
val categoryId: String?,
val type: Int,
val signature: ByteArray
) {
companion object {
private const val SERVER_REQUEST_SEQUENCE_NUMBER = "srvSeq"
private const val SENDER_DEVICE_ID = "senId"
private const val SENDER_SEQUENCE_NUMBER = "senSeq"
private const val DEVICE_ID = "deviceId"
private const val CATEGORY_ID = "categoryId"
private const val TYPE = "type"
private const val TEMP_KEY = "tempKey"
private const val SIGNATURE = "signature"
fun parse(reader: JsonReader): ServerKeyRequest {
var serverRequestSequenceNumber: Long? = null
var senderDeviceId: String? = null
var senderSequenceNumber: Long? = null
var tempKey: ByteArray? = null
var deviceId: String? = null
var categoryId: String? = null
var type: Int? = null
var signature: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
SERVER_REQUEST_SEQUENCE_NUMBER -> serverRequestSequenceNumber = reader.nextLong()
SENDER_DEVICE_ID -> senderDeviceId = reader.nextString()
SENDER_SEQUENCE_NUMBER -> senderSequenceNumber = reader.nextLong()
TEMP_KEY -> tempKey = reader.nextString().parseBase64()
DEVICE_ID -> deviceId = reader.nextString()
CATEGORY_ID -> categoryId = reader.nextString()
TYPE -> type = reader.nextInt()
SIGNATURE -> signature = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerKeyRequest(
serverRequestSequenceNumber = serverRequestSequenceNumber!!,
senderDeviceId = senderDeviceId!!,
senderSequenceNumber = senderSequenceNumber!!,
tempKey = tempKey!!,
deviceId = deviceId,
categoryId = categoryId,
type = type!!,
signature = signature!!
)
}
fun parseList(reader: JsonReader): List<ServerKeyRequest> = parseJsonArray(reader) { parse(reader) }
}
}
data class ServerKeyResponse(
val serverResponseSequenceNumber: Long,
val senderDeviceId: String,
val requestSequenceId: Long,
val tempKey: ByteArray,
val encryptedKey: ByteArray,
val signature: ByteArray
) {
companion object {
private const val SERVER_RESPONSE_SEQUENCE = "srvSeq"
private const val SENDER_DEVICE_ID = "sender"
private const val REQUEST_SEQUENCE_ID = "rqSeq"
private const val TEMP_KEY = "tempKey"
private const val ENCRYPTED_KEY = "cryptKey"
private const val SIGNATURE = "signature"
fun parse(reader: JsonReader): ServerKeyResponse {
var serverResponseSequenceNumber: Long? = null
var senderDeviceId: String? = null
var requestSequenceId: Long? = null
var tempKey: ByteArray? = null
var encryptedKey: ByteArray? = null
var signature: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
SERVER_RESPONSE_SEQUENCE -> serverResponseSequenceNumber = reader.nextLong()
SENDER_DEVICE_ID -> senderDeviceId = reader.nextString()
REQUEST_SEQUENCE_ID -> requestSequenceId = reader.nextLong()
TEMP_KEY -> tempKey = reader.nextString().parseBase64()
ENCRYPTED_KEY -> encryptedKey = reader.nextString().parseBase64()
SIGNATURE -> signature = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerKeyResponse(
serverResponseSequenceNumber = serverResponseSequenceNumber!!,
senderDeviceId = senderDeviceId!!,
requestSequenceId = requestSequenceId!!,
tempKey = tempKey!!,
encryptedKey = encryptedKey!!,
signature = signature!!
)
}
fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) }
}
}

View file

@ -195,6 +195,12 @@ data class DiagnoseExperimentalFlagItem(
enableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING, enableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING,
disableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING, disableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING,
enable = { true } enable = { true }
),
DiagnoseExperimentalFlagItem(
label = R.string.diagnose_exf_dls,
enableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING,
disableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING,
enable = { true }
) )
) )
} }

View file

@ -55,7 +55,7 @@ class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle {
binding.clearCacheBtn.setOnClickListener { binding.clearCacheBtn.setOnClickListener {
Threads.database.execute { Threads.database.execute {
UploadActionsUtil.deleteAllVersionNumbersSync(logic.database) UploadActionsUtil.deleteAllVersionNumbersSync(logic.database, wipeCryptoRequests = true)
} }
Toast.makeText(requireContext(), R.string.diagnose_sync_btn_clear_cache_toast, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.diagnose_sync_btn_clear_cache_toast, Toast.LENGTH_SHORT).show()

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,15 +24,15 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.lifecycle.switchMap
import androidx.navigation.Navigation import androidx.navigation.Navigation
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.crypto.Curve25519
import io.timelimit.android.crypto.HexString
import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.Device
import io.timelimit.android.databinding.FragmentManageDeviceBinding import io.timelimit.android.databinding.FragmentManageDeviceBinding
import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.extensions.safeNavigate
import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.*
import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.RealTime import io.timelimit.android.logic.RealTime
@ -150,6 +150,26 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
binding.isThisDevice = it binding.isThisDevice = it
}) })
val signingKey = isThisDevice.switchMap { isLocalDevice ->
if (isLocalDevice) {
logic.fullVersion.isLocalMode.switchMap { isLocalMode ->
if (isLocalMode) liveDataFromNullableValue(null)
else
logic.database.config().getSigningKeyAsync().map {
if (it != null) Curve25519.getPublicKey(it)
else null
}
}
} else {
logic.database.deviceKey().getLive(args.deviceId).map {
it?.publicKey
}
}
}.map {
if (it == null) null
else HexString.toHex(it)
}
ManageDeviceIntroduction.bind( ManageDeviceIntroduction.bind(
view = binding.introduction, view = binding.introduction,
database = logic.database, database = logic.database,
@ -182,6 +202,8 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none) binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none)
}) })
signingKey.observe(viewLifecycleOwner) { binding.devicePublicKey = it }
return binding.root return binding.root
} }

View file

@ -0,0 +1,59 @@
/*
* 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/>.
*/
syntax = "proto3";
package io.timelimit.proto.applist;
message InstalledAppProto {
string package_name = 1;
string title = 2;
bool is_launchable = 3;
Recommendation recommendation = 4;
enum Recommendation {
NONE = 0;
WHITELIST = 1;
BLACKLIST = 2;
}
}
message InstalledAppActivityProto {
string package_name = 1;
string class_name = 2;
string title = 3;
}
message RemovedAppActivityProto {
string package_name = 1;
string class_name = 2;
}
message InstalledAppsProto {
repeated InstalledAppProto apps = 1;
repeated InstalledAppActivityProto activities = 2;
}
message InstalledAppsDifferenceProto {
InstalledAppsProto added = 1;
repeated string removed_packages = 2;
repeated RemovedAppActivityProto removed_activities = 3;
}
message SavedAppsDifferenceProto {
InstalledAppsDifferenceProto apps = 1;
int64 base_generation = 2;
int64 base_counter = 3;
}

View file

@ -50,8 +50,13 @@
name="handlers" name="handlers"
type="io.timelimit.android.ui.manage.device.manage.ManageDeviceFragmentHandlers" /> type="io.timelimit.android.ui.manage.device.manage.ManageDeviceFragmentHandlers" />
<variable
name="devicePublicKey"
type="String" />
<import type="android.view.View" /> <import type="android.view.View" />
<import type="io.timelimit.android.BuildConfig" /> <import type="io.timelimit.android.BuildConfig" />
<import type="android.text.TextUtils" />
</data> </data>
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
@ -107,6 +112,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(devicePublicKey) ? View.GONE : View.VISIBLE}"
android:text="@{@string/manage_device_signing_key(devicePublicKey)}"
android:textAppearance="?android:textAppearanceMedium"
tools:text="Signing Key: 1234"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView <TextView
android:visibility="@{safeUnbox(isThisDevice) ? View.VISIBLE : View.GONE}" android:visibility="@{safeUnbox(isThisDevice) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceMedium" android:textAppearance="?android:textAppearanceMedium"

View file

@ -531,6 +531,7 @@
<string name="diagnose_exf_esb">Overlay und Home-Button nicht zum Sperren verwenden</string> <string name="diagnose_exf_esb">Overlay und Home-Button nicht zum Sperren verwenden</string>
<string name="diagnose_exf_srn">Toasts zur Synchronisation anzeigen</string> <string name="diagnose_exf_srn">Toasts zur Synchronisation anzeigen</string>
<string name="diagnose_exf_soc">strengere Prüfung der Überlagerungs-Berechtigung aktivieren</string> <string name="diagnose_exf_soc">strengere Prüfung der Überlagerungs-Berechtigung aktivieren</string>
<string name="diagnose_exf_dls">deaktiviere Senden der App-Liste im alten Format</string>
<string name="diagnose_bg_task_loop_ex">Hintergrundaufgabenschleifenfehler</string> <string name="diagnose_bg_task_loop_ex">Hintergrundaufgabenschleifenfehler</string>
@ -894,6 +895,7 @@
<string name="manage_device_added_at">Hinzugefügt: %s</string> <string name="manage_device_added_at">Hinzugefügt: %s</string>
<string name="manage_device_is_this_device">Das ist das Gerät, das Sie gerade vor sich haben</string> <string name="manage_device_is_this_device">Das ist das Gerät, das Sie gerade vor sich haben</string>
<string name="manage_device_signing_key">Signaturschlüssel: %s</string>
<string name="manage_device_current_user_title">Benutzer des Geräts</string> <string name="manage_device_current_user_title">Benutzer des Geräts</string>
<string name="manage_device_current_user_none">Keine Angabe - keine Begrenzungen</string> <string name="manage_device_current_user_none">Keine Angabe - keine Begrenzungen</string>
@ -1144,6 +1146,9 @@
<string name="notification_channel_reset_title">Reset-Benachrichtigung</string> <string name="notification_channel_reset_title">Reset-Benachrichtigung</string>
<string name="notification_channel_reset_text">Zeigt eine Benachrichtigung an, wenn TimeLimit an diesem Gerät zurückgesetzt wurde</string> <string name="notification_channel_reset_text">Zeigt eine Benachrichtigung an, wenn TimeLimit an diesem Gerät zurückgesetzt wurde</string>
<string name="notification_channel_new_device_title">neue Geräte</string>
<string name="notification_channel_new_device_description">Zeigt eine Benachrichtigung an, wenn TimeLimit mit Vernetzung verwendet wird und ein neues Gerät verknüpft wurde</string>
<string name="notification_filter_not_blocked_title">TimeLimit hat eine Benachrichtigung blockiert</string> <string name="notification_filter_not_blocked_title">TimeLimit hat eine Benachrichtigung blockiert</string>
<string name="notification_filter_blocking_failed_title">TimeLimit konnte eine Benachrichtigung nicht blockieren</string> <string name="notification_filter_blocking_failed_title">TimeLimit konnte eine Benachrichtigung nicht blockieren</string>
@ -1157,6 +1162,8 @@
<string name="notification_background_sync_title">TimeLimit ist aktiv</string> <string name="notification_background_sync_title">TimeLimit ist aktiv</string>
<string name="notification_background_sync_text">eine Synchronisation läuft</string> <string name="notification_background_sync_text">eine Synchronisation läuft</string>
<string name="notification_new_device_title">Gerät hinzugefügt</string>
<string name="obsolete_message">Sie verwenden TimeLimit auf einer älteren Android-Version. <string name="obsolete_message">Sie verwenden TimeLimit auf einer älteren Android-Version.
Das kann funktionieren, aber es wird nicht empfohlen. Das kann funktionieren, aber es wird nicht empfohlen.
</string> </string>

View file

@ -105,6 +105,8 @@
(<a href="https://github.com/signalapp/curve25519-java/blob/master/LICENSE">GNU General Public License v3.0</a>) (<a href="https://github.com/signalapp/curve25519-java/blob/master/LICENSE">GNU General Public License v3.0</a>)
\n<a href="https://github.com/zxing/zxing">ZXing</a> \n<a href="https://github.com/zxing/zxing">ZXing</a>
(<a href="https://github.com/zxing/zxing/blob/master/LICENSE">Apache License 2.0</a>) (<a href="https://github.com/zxing/zxing/blob/master/LICENSE">Apache License 2.0</a>)
\n<a href="https://github.com/square/wire/blob/master/README.md">Wire</a>
(<a href="https://github.com/square/wire/blob/master/LICENSE.txt">Apache License 2.0</a>)
</string> </string>
<string name="about_diagnose_title">Error diagnose</string> <string name="about_diagnose_title">Error diagnose</string>
@ -581,6 +583,7 @@
<string name="diagnose_exf_esb">Do not use a overlay or the home button for blocking</string> <string name="diagnose_exf_esb">Do not use a overlay or the home button for blocking</string>
<string name="diagnose_exf_srn">Show sync related toasts</string> <string name="diagnose_exf_srn">Show sync related toasts</string>
<string name="diagnose_exf_soc">Enable strict overlay permission check</string> <string name="diagnose_exf_soc">Enable strict overlay permission check</string>
<string name="diagnose_exf_dls">Disable sending the App List with the legacy encoding</string>
<string name="diagnose_bg_task_loop_ex">Background task loop exception</string> <string name="diagnose_bg_task_loop_ex">Background task loop exception</string>
@ -937,6 +940,7 @@
<string name="manage_device_added_at">Added: %s</string> <string name="manage_device_added_at">Added: %s</string>
<string name="manage_device_is_this_device">This is the device which is in front of you</string> <string name="manage_device_is_this_device">This is the device which is in front of you</string>
<string name="manage_device_signing_key">Signing Key: %s</string>
<string name="manage_device_current_user_title">User of the device</string> <string name="manage_device_current_user_title">User of the device</string>
<string name="manage_device_current_user_none">No selection - no limits</string> <string name="manage_device_current_user_none">No selection - no limits</string>
@ -1186,6 +1190,9 @@
<string name="notification_channel_reset_title">Reset Notification</string> <string name="notification_channel_reset_title">Reset Notification</string>
<string name="notification_channel_reset_text">Shows a notification if TimeLimit was reset at this device</string> <string name="notification_channel_reset_text">Shows a notification if TimeLimit was reset at this device</string>
<string name="notification_channel_new_device_title">New Device</string>
<string name="notification_channel_new_device_description">Shows a notification if the connected mode is used and a new device was linked</string>
<string name="notification_filter_not_blocked_title">TimeLimit has blocked a notification</string> <string name="notification_filter_not_blocked_title">TimeLimit has blocked a notification</string>
<string name="notification_filter_blocking_failed_title">TimeLimit could not block a notification</string> <string name="notification_filter_blocking_failed_title">TimeLimit could not block a notification</string>
@ -1199,6 +1206,8 @@
<string name="notification_background_sync_title">TimeLimit is running</string> <string name="notification_background_sync_title">TimeLimit is running</string>
<string name="notification_background_sync_text">a background synchronisation is in progress</string> <string name="notification_background_sync_text">a background synchronisation is in progress</string>
<string name="notification_new_device_title">Device added</string>
<string name="obsolete_message">You are using TimeLimit at a obsolete Android version. <string name="obsolete_message">You are using TimeLimit at a obsolete Android version.
Although this can work, it is not recommend. Although this can work, it is not recommend.
</string> </string>

View file

@ -26,6 +26,7 @@ buildscript {
classpath 'com.android.tools.build:gradle:7.2.1' classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.2" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.2"
classpath 'com.squareup.wire:wire-gradle-plugin:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files