Первые тесты
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
@@ -5,6 +7,13 @@ plugins {
|
|||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val localProps = Properties().apply {
|
||||||
|
val file = rootProject.file("local.properties")
|
||||||
|
if (file.exists()) {
|
||||||
|
file.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.github.nullptroma.wallenc.app"
|
namespace = "com.github.nullptroma.wallenc.app"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@@ -17,6 +26,12 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
testInstrumentationRunnerArguments["yandex.oauth.token"] =
|
||||||
|
localProps.getProperty("yandex.test.oauth.token").orEmpty()
|
||||||
|
testInstrumentationRunnerArguments["yandex.user.id"] =
|
||||||
|
localProps.getProperty("yandex.test.user.id").orEmpty()
|
||||||
|
testInstrumentationRunnerArguments["yandex.vault.uuid"] =
|
||||||
|
localProps.getProperty("yandex.test.vault.uuid").orEmpty()
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
}
|
}
|
||||||
@@ -78,6 +93,14 @@ dependencies {
|
|||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.okhttp3)
|
||||||
|
androidTestImplementation(libs.retrofit)
|
||||||
|
androidTestImplementation(libs.retrofit.converter.jackson)
|
||||||
|
androidTestImplementation(libs.jackson.module.kotlin)
|
||||||
|
androidTestImplementation(libs.jackson.datatype.jsr310)
|
||||||
|
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":usecases"))
|
implementation(project(":usecases"))
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.app
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("com.github.nullptroma.wallenc.app", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.github.nullptroma.wallenc.app.integration.yandex
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Ignore
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ручной helper: после входа в Yandex через приложение печатает в Logcat маскированный
|
||||||
|
* токен и подсказку для local.properties. Запускать вручную из Android Studio.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@Ignore("Manual: export Yandex credentials to local.properties")
|
||||||
|
class ExportYandexTestCredentialsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun printFirstAccountCredentialsToLogcat() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
val db = Room.databaseBuilder(context, AppDb::class.java, "wallenc.db")
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
val row = runBlocking {
|
||||||
|
db.yandexAccountDao.observeAll().first().firstOrNull()
|
||||||
|
}
|
||||||
|
if (row == null) {
|
||||||
|
Log.i(TAG, "No Yandex accounts in DB. Link a vault in the app first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Add to local.properties:")
|
||||||
|
Log.i(TAG, "yandex.test.oauth.token=<copy full token from app debug storage if needed>")
|
||||||
|
Log.i(TAG, "yandex.test.oauth.token.prefix=${maskToken(row.oauthToken)}")
|
||||||
|
Log.i(TAG, "yandex.test.user.id=${row.yandexUserId}")
|
||||||
|
Log.i(TAG, "yandex.test.vault.uuid=${row.vaultUuid}")
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskToken(token: String): String {
|
||||||
|
if (token.length <= 8) return "***"
|
||||||
|
return token.take(4) + "…" + token.takeLast(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WallencYandexExport"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.github.nullptroma.wallenc.app.integration.yandex
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskApiFactory
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.JUnit4
|
||||||
|
|
||||||
|
@RunWith(JUnit4::class)
|
||||||
|
class YandexDiskLiveIntegrationTest {
|
||||||
|
|
||||||
|
private lateinit var repository: YandexDiskRepository
|
||||||
|
private val testFolder = "disk:/wallenc-integration-test"
|
||||||
|
private val probeFileName = "wallenc-probe.txt"
|
||||||
|
private val probePath = "$testFolder/$probeFileName"
|
||||||
|
private val probePayload = "wallenc-integration-probe".encodeToByteArray()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
YandexTestCredentials.assumePresent()
|
||||||
|
val token = YandexTestCredentials.oauthToken()!!
|
||||||
|
repository = YandexDiskApiFactory.createRepositoryWithToken(token, Dispatchers.IO)
|
||||||
|
runBlocking {
|
||||||
|
runCatching { repository.createFolder(testFolder) }
|
||||||
|
runCatching {
|
||||||
|
repository.uploadBytes(probePath, probePayload, overwrite = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = runBlocking {
|
||||||
|
runCatching { repository.delete(probePath, permanently = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun diskInfoReturnsQuota() = runBlocking {
|
||||||
|
val info = repository.diskInfo()
|
||||||
|
assertNotNull(info.totalSpace)
|
||||||
|
assertTrue(info.totalSpace!! > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listTestFolderDoesNotThrow() = runBlocking {
|
||||||
|
val result = repository.list(testFolder, limit = 10, offset = 0)
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uploadAndDownloadRoundTrip() = runBlocking {
|
||||||
|
val path = "$testFolder/roundtrip-${System.currentTimeMillis()}.bin"
|
||||||
|
try {
|
||||||
|
repository.uploadBytes(path, probePayload, overwrite = true)
|
||||||
|
val downloaded = repository.openDownloadStream(path).use { it.readBytes() }
|
||||||
|
assertTrue(downloaded.contentEquals(probePayload))
|
||||||
|
} finally {
|
||||||
|
runCatching { repository.delete(path, permanently = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.github.nullptroma.wallenc.app.integration.yandex
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assume.assumeFalse
|
||||||
|
|
||||||
|
object YandexTestCredentials {
|
||||||
|
fun oauthToken(): String? =
|
||||||
|
InstrumentationRegistry.getArguments().getString("yandex.oauth.token")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
fun userId(): String? =
|
||||||
|
InstrumentationRegistry.getArguments().getString("yandex.user.id")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
fun vaultUuid(): String? =
|
||||||
|
InstrumentationRegistry.getArguments().getString("yandex.vault.uuid")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
fun assumePresent(message: String = "Добавьте yandex.test.oauth.token в local.properties") {
|
||||||
|
assumeFalse(message, oauthToken().isNullOrBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ package com.github.nullptroma.wallenc.app.di.modules.data
|
|||||||
|
|
||||||
import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher
|
import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageKeyMapRepository
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageKeyMapRepository
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageSyncGroupRepository
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageSyncGroupRepository
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.YandexAccountRepository
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.YandexAccountRepository
|
||||||
import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore
|
import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore
|
||||||
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.github.nullptroma.wallenc.app.di.modules.data
|
package com.github.nullptroma.wallenc.app.di.modules.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.RoomFactory
|
import com.github.nullptroma.wallenc.infrastructure.android.db.RoomFactory
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.IAppDb
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.IAppDb
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|||||||
15
app/src/main/res/values-ru/plurals.xml
Normal file
15
app/src/main/res/values-ru/plurals.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<plurals name="task_notification_group_subtext">
|
||||||
|
<item quantity="one">Выполняется %d задача</item>
|
||||||
|
<item quantity="few">Выполняется %d задачи</item>
|
||||||
|
<item quantity="many">Выполняется %d задач</item>
|
||||||
|
<item quantity="other">Выполняется %d задач</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="task_notification_more_tasks">
|
||||||
|
<item quantity="one">ещё %d</item>
|
||||||
|
<item quantity="few">ещё %d</item>
|
||||||
|
<item quantity="many">ещё %d</item>
|
||||||
|
<item quantity="other">ещё %d</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.app
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,8 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.mockwebserver)
|
||||||
|
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":vault-contracts"))
|
implementation(project(":vault-contracts"))
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("com.github.nullptroma.wallenc.domain.vault.test", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk
|
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
|
||||||
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -72,7 +73,30 @@ class YandexDiskApiFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BASE_URL = "https://cloud-api.yandex.net/"
|
const val BASE_URL = "https://cloud-api.yandex.net/"
|
||||||
private const val OAUTH_TOKEN_CACHE_TTL_MS = 120_000L
|
private const val OAUTH_TOKEN_CACHE_TTL_MS = 120_000L
|
||||||
|
|
||||||
|
fun createRepositoryWithToken(
|
||||||
|
oauthToken: String,
|
||||||
|
ioDispatcher: CoroutineDispatcher,
|
||||||
|
): YandexDiskRepository {
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
chain.proceed(
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.header("Authorization", "OAuth $oauthToken")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
val api = Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(JacksonConverterFactory.create(jacksonObjectMapper().findAndRegisterModules()))
|
||||||
|
.build()
|
||||||
|
.create(YandexDiskApi::class.java)
|
||||||
|
return YandexDiskRepository(api, OkHttpClient(), ioDispatcher)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.vault.errors
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class VaultThrowableMappingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsYandexDiskAuthToAuthFailed() {
|
||||||
|
val mapped = YandexDiskAuthException("unauthorized").toVaultWallencException()
|
||||||
|
assertEquals(WallencException.Auth.Failed, mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsHttpExceptionToNetworkHttpFailed() {
|
||||||
|
val response = Response.error<String>(401, "".toResponseBody(null))
|
||||||
|
val mapped = HttpException(response).toVaultWallencException()
|
||||||
|
assertTrue(mapped is WallencException.Network.HttpFailed)
|
||||||
|
assertEquals(401, (mapped as WallencException.Network.HttpFailed).statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsMissingOAuthTokenIoToTokenMissing() {
|
||||||
|
val mapped = IOException("Yandex OAuth token is missing").toVaultWallencException()
|
||||||
|
assertEquals(WallencException.Auth.TokenMissing, mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsFileNotFoundToStorageFileNotFound() {
|
||||||
|
val mapped = FileNotFoundException("x").toVaultWallencException()
|
||||||
|
assertEquals(WallencException.Storage.FileNotFound, mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsIllegalStateNotAFile() {
|
||||||
|
val mapped = IllegalStateException("Not a file").toVaultWallencException()
|
||||||
|
assertEquals(WallencException.Storage.NotAFile, mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.jackson.JacksonConverterFactory
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object YandexDiskRepositoryTestFactory {
|
||||||
|
|
||||||
|
private val jackson = jacksonObjectMapper().findAndRegisterModules()
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
baseUrl: String,
|
||||||
|
oauthToken: String,
|
||||||
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
rawHttp: OkHttpClient = OkHttpClient(),
|
||||||
|
): YandexDiskRepository {
|
||||||
|
val api = createApi(baseUrl) { oauthToken }
|
||||||
|
return YandexDiskRepository(api, rawHttp, ioDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createApi(
|
||||||
|
baseUrl: String,
|
||||||
|
tokenProvider: () -> String?,
|
||||||
|
): YandexDiskApi {
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val token = tokenProvider()
|
||||||
|
?: throw IOException("Yandex OAuth token is missing")
|
||||||
|
chain.proceed(
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.header("Authorization", "OAuth $token")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(JacksonConverterFactory.create(jackson))
|
||||||
|
.build()
|
||||||
|
.create(YandexDiskApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskRepositoryTestFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class YandexDiskRepositoryTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var repository: YandexDiskRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
val api = YandexDiskRepositoryTestFactory.createApi(server.url("/").toString()) { "test-token" }
|
||||||
|
repository = YandexDiskRepository(api, OkHttpClient(), Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun diskInfoParsesResponse() = runBlocking {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{"total_space":1000,"used_space":200,"trash_size":0}""")
|
||||||
|
.addHeader("Content-Type", "application/json"),
|
||||||
|
)
|
||||||
|
val info = repository.diskInfo()
|
||||||
|
assertEquals(1000L, info.totalSpace)
|
||||||
|
assertEquals(200L, info.usedSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listReturnsEmptyEmbeddedOn404() = runBlocking {
|
||||||
|
repeat(2) {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404))
|
||||||
|
}
|
||||||
|
val result = repository.list("disk:/missing", limit = 10, offset = 0)
|
||||||
|
assertTrue(result.embedded?.items?.isEmpty() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun diskInfoThrowsAuthExceptionOn401() = runBlocking {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
try {
|
||||||
|
repository.diskInfo()
|
||||||
|
error("Expected YandexDiskAuthException")
|
||||||
|
} catch (_: YandexDiskAuthException) {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,4 +16,5 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ kotlin = "2.3.21"
|
|||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
|
androidxTestCore = "1.7.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
kotlinReflect = "2.3.21"
|
kotlinReflect = "2.3.21"
|
||||||
kotlinxCoroutinesCore = "1.11.0"
|
kotlinxCoroutinesCore = "1.11.0"
|
||||||
@@ -27,13 +28,24 @@ cameraX = "1.6.1"
|
|||||||
mlkitBarcode = "17.3.0"
|
mlkitBarcode = "17.3.0"
|
||||||
javaOtp = "0.4.0"
|
javaOtp = "0.4.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.1"
|
||||||
|
mockk = "1.14.9"
|
||||||
|
robolectric = "4.16.1"
|
||||||
|
androidxArchCore = "2.2.0"
|
||||||
|
turbine = "1.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
|
||||||
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
|
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
|
||||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" }
|
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore" }
|
||||||
|
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||||
|
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||||
|
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||||
|
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||||
|
androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArchCore" }
|
||||||
|
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" }
|
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" }
|
||||||
@@ -66,6 +78,8 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
|
|||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestCore" }
|
||||||
|
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
consumerProguardFiles("consumer-rules.pro")
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,6 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -44,4 +44,8 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
}
|
}
|
||||||
|
|||||||
1
infrastructure-android/consumer-rules.pro
Normal file
1
infrastructure-android/consumer-rules.pro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Consumer ProGuard rules for infrastructure-android (empty).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.github.nullptroma.wallenc.domain.vault.model.YandexAccount
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class YandexAccountRepositoryTest {
|
||||||
|
|
||||||
|
private lateinit var db: AppDb
|
||||||
|
private lateinit var repository: YandexAccountRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, AppDb::class.java).build()
|
||||||
|
repository = YandexAccountRepository(
|
||||||
|
dao = db.yandexAccountDao,
|
||||||
|
ioDispatcher = Dispatchers.IO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertAndLoadByVaultUuid() = runBlocking {
|
||||||
|
val account = YandexAccount(
|
||||||
|
vaultUuid = "vault-1",
|
||||||
|
yandexUserId = "user-1",
|
||||||
|
email = "test@yandex.ru",
|
||||||
|
oauthToken = "token-abc",
|
||||||
|
)
|
||||||
|
repository.insert(account)
|
||||||
|
val loaded = repository.getByVaultUuid("vault-1")
|
||||||
|
assertEquals(account, loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateCredentialsChangesToken() = runBlocking {
|
||||||
|
repository.insert(
|
||||||
|
YandexAccount(
|
||||||
|
vaultUuid = "vault-2",
|
||||||
|
yandexUserId = "user-2",
|
||||||
|
email = "old@yandex.ru",
|
||||||
|
oauthToken = "old-token",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.updateCredentials("vault-2", "new@yandex.ru", "new-token")
|
||||||
|
val loaded = repository.getByVaultUuid("vault-2")
|
||||||
|
assertEquals("new@yandex.ru", loaded?.email)
|
||||||
|
assertEquals("new-token", loaded?.oauthToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteByVaultUuidRemovesRow() = runBlocking {
|
||||||
|
repository.insert(
|
||||||
|
YandexAccount(
|
||||||
|
vaultUuid = "vault-3",
|
||||||
|
yandexUserId = "user-3",
|
||||||
|
email = "x@yandex.ru",
|
||||||
|
oauthToken = "t",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.deleteByVaultUuid("vault-3")
|
||||||
|
assertNull(repository.getByVaultUuid("vault-3"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db
|
package com.github.nullptroma.wallenc.infrastructure.android.db
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.AppDb
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb
|
||||||
|
|
||||||
class RoomFactory(private val context: Context) {
|
class RoomFactory(private val context: Context) {
|
||||||
fun buildAppDb(): AppDb {
|
fun buildAppDb(): AppDb {
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup
|
||||||
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount
|
||||||
|
|
||||||
|
interface IAppDb {
|
||||||
|
val storageKeyMapDao: StorageKeyMapDao
|
||||||
|
val storageMetaInfoDao: StorageMetaInfoDao
|
||||||
|
val storageSyncGroupDao: StorageSyncGroupDao
|
||||||
|
val yandexAccountDao: YandexAccountDao
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class],
|
||||||
|
version = 5,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class AppDb : IAppDb, RoomDatabase() {
|
||||||
|
abstract override val storageKeyMapDao: StorageKeyMapDao
|
||||||
|
abstract override val storageMetaInfoDao: StorageMetaInfoDao
|
||||||
|
abstract override val storageSyncGroupDao: StorageSyncGroupDao
|
||||||
|
abstract override val yandexAccountDao: YandexAccountDao
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.dao
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface StorageKeyMapDao {
|
interface StorageKeyMapDao {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.dao
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.dao
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface StorageSyncGroupDao {
|
interface StorageSyncGroupDao {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.dao
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.model
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.model
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap
|
import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(tableName = "storage_key_maps")
|
@Entity(tableName = "storage_key_maps")
|
||||||
data class DbStorageKeyMap(
|
data class DbStorageKeyMap(
|
||||||
@androidx.room.PrimaryKey
|
@PrimaryKey
|
||||||
@ColumnInfo(name = "source_uuid") val sourceUuid: UUID,
|
@ColumnInfo(name = "source_uuid") val sourceUuid: UUID,
|
||||||
@ColumnInfo(name = "key") val key: ByteArray
|
@ColumnInfo(name = "key") val key: ByteArray
|
||||||
) {
|
) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.model
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.model
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.model
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.model
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.model
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.model
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.repository
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap
|
||||||
import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap
|
import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap
|
||||||
import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore
|
import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.repository
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
|
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.repository
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup
|
||||||
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
|
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app.repository
|
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount
|
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount
|
||||||
import com.github.nullptroma.wallenc.domain.vault.model.YandexAccount
|
import com.github.nullptroma.wallenc.domain.vault.model.YandexAccount
|
||||||
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.vault.db.app
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup
|
|
||||||
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount
|
|
||||||
|
|
||||||
interface IAppDb {
|
|
||||||
val storageKeyMapDao: StorageKeyMapDao
|
|
||||||
val storageMetaInfoDao: StorageMetaInfoDao
|
|
||||||
val storageSyncGroupDao: StorageSyncGroupDao
|
|
||||||
val yandexAccountDao: YandexAccountDao
|
|
||||||
}
|
|
||||||
|
|
||||||
@Database(
|
|
||||||
entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class],
|
|
||||||
version = 5,
|
|
||||||
exportSchema = false
|
|
||||||
)
|
|
||||||
abstract class AppDb : IAppDb, RoomDatabase() {
|
|
||||||
abstract override val storageKeyMapDao: StorageKeyMapDao
|
|
||||||
abstract override val storageMetaInfoDao: StorageMetaInfoDao
|
|
||||||
abstract override val storageSyncGroupDao: StorageSyncGroupDao
|
|
||||||
abstract override val yandexAccountDao: YandexAccountDao
|
|
||||||
}
|
|
||||||
@@ -17,4 +17,5 @@ dependencies {
|
|||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.github.nullptroma.wallenc.task.runtime
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.errors.WallencException
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TaskOrchestratorTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enqueueCompletesTask() = runTest(dispatcher) {
|
||||||
|
val orchestrator = TaskOrchestrator(dispatcher)
|
||||||
|
val id = orchestrator.enqueue(
|
||||||
|
title = "Test",
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
work = { ctx ->
|
||||||
|
ctx.reportProgress(0.5f, TaskProgressLabel.SyncPreparing(1))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.first { it.id == id }
|
||||||
|
assertTrue(task.state is TaskRunState.Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelMarksTaskCancelled() = runTest(dispatcher) {
|
||||||
|
val orchestrator = TaskOrchestrator(dispatcher)
|
||||||
|
val id = orchestrator.enqueue(
|
||||||
|
title = "Long",
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
work = { ctx ->
|
||||||
|
ctx.reportProgress(null, null)
|
||||||
|
kotlinx.coroutines.delay(60_000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
advanceTimeBy(1)
|
||||||
|
orchestrator.cancel(id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.first { it.id == id }
|
||||||
|
assertTrue(task.state is TaskRunState.Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failRecordsFailedState() = runTest(dispatcher) {
|
||||||
|
val orchestrator = TaskOrchestrator(dispatcher)
|
||||||
|
val id = orchestrator.enqueue(
|
||||||
|
title = "Fail",
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
work = { ctx ->
|
||||||
|
ctx.fail(WallencException.Storage.FileNotFound)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.first { it.id == id }
|
||||||
|
val failed = task.state as TaskRunState.Failed
|
||||||
|
assertEquals(WallencException.Storage.FileNotFound, failed.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressUpdatesRunningState() = runTest(dispatcher) {
|
||||||
|
val orchestrator = TaskOrchestrator(dispatcher)
|
||||||
|
val id = orchestrator.enqueue(
|
||||||
|
title = "Progress",
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
work = { ctx ->
|
||||||
|
ctx.reportProgress(0.25f, TaskProgressLabel.SyncCompleted)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.first { it.id == id }
|
||||||
|
assertTrue(task.state is TaskRunState.Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logAppendsLine() = runTest(dispatcher) {
|
||||||
|
val orchestrator = TaskOrchestrator(dispatcher)
|
||||||
|
orchestrator.enqueue(
|
||||||
|
title = "Log",
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
work = { ctx ->
|
||||||
|
ctx.log(TaskLogLevel.Info, "hello")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(orchestrator.logLines.value.any { it.message == "hello" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
@@ -79,6 +83,10 @@ dependencies {
|
|||||||
implementation(libs.mlkit.barcode.scanning)
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.androidx.arch.core.testing)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
@@ -87,4 +95,6 @@ dependencies {
|
|||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":usecases"))
|
implementation(project(":usecases"))
|
||||||
implementation(project(":vault-contracts"))
|
implementation(project(":vault-contracts"))
|
||||||
|
|
||||||
|
testImplementation(project(":task-runtime"))
|
||||||
}
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.ui
|
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ExampleInstrumentedTest {
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
// Context of the app under test.
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("com.github.nullptroma.wallenc.ui.test", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TextSecretsScreenContentTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsEmptyStateWhenNoSecrets() {
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
TextSecretsScreenContent(
|
||||||
|
uiState = TextSecretsScreenState(
|
||||||
|
isLoading = false,
|
||||||
|
isAvailable = true,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText(context.getString(R.string.text_secret_empty_state)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsSecretTitle() {
|
||||||
|
val secret = TextSecretRecord(
|
||||||
|
id = "s1",
|
||||||
|
title = "Production DB",
|
||||||
|
items = listOf(TextSecretEntryRecord(label = "user", value = "admin")),
|
||||||
|
)
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
TextSecretsScreenContent(
|
||||||
|
uiState = TextSecretsScreenState(
|
||||||
|
isLoading = false,
|
||||||
|
isAvailable = true,
|
||||||
|
items = listOf(secret),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
TextSecretListCard(
|
||||||
|
secret = secret,
|
||||||
|
onClick = {},
|
||||||
|
enabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText("Production DB").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TwoFaTokensScreenContentTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsEmptyStateWhenNoTokens() {
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
TwoFaTokensScreenContent(
|
||||||
|
uiState = TwoFaTokensScreenState(
|
||||||
|
isLoading = false,
|
||||||
|
isAvailable = true,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText(context.getString(R.string.two_fa_empty_state)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsIssuerAndAccountForToken() {
|
||||||
|
val token = TwoFaTokenRecord(
|
||||||
|
id = "1",
|
||||||
|
issuer = "GitHub",
|
||||||
|
account = "user@example.com",
|
||||||
|
secret = "SECRET",
|
||||||
|
)
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
TwoFaTokensScreenContent(
|
||||||
|
uiState = TwoFaTokensScreenState(
|
||||||
|
isLoading = false,
|
||||||
|
isAvailable = true,
|
||||||
|
items = listOf(token),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
TwoFaTokenListHeader(
|
||||||
|
issuer = token.issuer,
|
||||||
|
account = token.account,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText("GitHub").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("user@example.com").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,16 @@
|
|||||||
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.Notes
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.outlined.Notes
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -33,7 +21,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
||||||
import com.github.nullptroma.wallenc.ui.R
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextSecretsScreen(
|
fun TextSecretsScreen(
|
||||||
@@ -60,99 +47,19 @@ fun TextSecretsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
TextSecretsScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding),
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
uiState.errorNotification?.let { notification ->
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text(
|
items(uiState.items) { secret ->
|
||||||
text = notification.resolveText(),
|
TextSecretListCard(
|
||||||
color = MaterialTheme.colorScheme.error,
|
secret = secret,
|
||||||
)
|
onClick = { onOpenSecret(secret) },
|
||||||
}
|
enabled = uiState.isAvailable,
|
||||||
|
)
|
||||||
if (uiState.items.isEmpty()) {
|
|
||||||
Text(stringResource(R.string.text_secret_empty_state))
|
|
||||||
} else {
|
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
items(uiState.items) { secret ->
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = { onOpenSecret(secret) },
|
|
||||||
enabled = uiState.isAvailable,
|
|
||||||
colors = CardDefaults.elevatedCardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(14.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Outlined.Notes,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(22.dp)
|
|
||||||
.padding(top = 2.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = secret.title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_secret_items_count, secret.items.size),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
val fieldNames = secret.items
|
|
||||||
.map { item ->
|
|
||||||
item.label
|
|
||||||
?.trim()
|
|
||||||
.takeUnless { it.isNullOrBlank() }
|
|
||||||
?: stringResource(R.string.text_secret_item_without_label)
|
|
||||||
}
|
|
||||||
if (fieldNames.isNotEmpty()) {
|
|
||||||
fieldNames.take(3).forEach { fieldName ->
|
|
||||||
Text(
|
|
||||||
text = "\u2022 $fieldName",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val hiddenCount = fieldNames.size - 3
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.text_secret_more_fields,
|
|
||||||
hiddenCount,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Notes
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextSecretsScreenContent(
|
||||||
|
uiState: TextSecretsScreenState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
secretList: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
uiState.errorNotification?.let { notification ->
|
||||||
|
Text(
|
||||||
|
text = notification.resolveText(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.items.isEmpty()) {
|
||||||
|
Text(stringResource(R.string.text_secret_empty_state))
|
||||||
|
} else {
|
||||||
|
secretList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextSecretListCard(
|
||||||
|
secret: TextSecretRecord,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
colors = CardDefaults.elevatedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.Notes,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.padding(top = 2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = secret.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_secret_items_count, secret.items.size),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val fieldNames = secret.items
|
||||||
|
.map { item ->
|
||||||
|
item.label
|
||||||
|
?.trim()
|
||||||
|
.takeUnless { it.isNullOrBlank() }
|
||||||
|
?: stringResource(R.string.text_secret_item_without_label)
|
||||||
|
}
|
||||||
|
if (fieldNames.isNotEmpty()) {
|
||||||
|
fieldNames.take(3).forEach { fieldName ->
|
||||||
|
Text(
|
||||||
|
text = "\u2022 $fieldName",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val hiddenCount = fieldNames.size - 3
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.text_secret_more_fields,
|
||||||
|
hiddenCount,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,30 +122,14 @@ fun TwoFaTokensScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
TwoFaTokensScreenContent(
|
||||||
|
uiState = uiState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding),
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
CircularProgressIndicator()
|
items(uiState.items) { item ->
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.errorNotification?.let { notification ->
|
|
||||||
Text(
|
|
||||||
text = notification.resolveText(),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.items.isEmpty()) {
|
|
||||||
Text(stringResource(R.string.two_fa_empty_state))
|
|
||||||
} else {
|
|
||||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
items(uiState.items) { item ->
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.elevatedCardColors(
|
colors = CardDefaults.elevatedCardColors(
|
||||||
@@ -159,30 +143,11 @@ fun TwoFaTokensScreen(
|
|||||||
.padding(14.dp),
|
.padding(14.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Row(
|
TwoFaTokenListHeader(
|
||||||
|
issuer = item.issuer,
|
||||||
|
account = item.account,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(22.dp)
|
|
||||||
.padding(top = 2.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = item.issuer,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.account,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row {
|
Row {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { editingToken = item },
|
onClick = { editingToken = item },
|
||||||
@@ -282,7 +247,6 @@ fun TwoFaTokensScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.nullptroma.wallenc.ui.R
|
||||||
|
import com.github.nullptroma.wallenc.ui.resources.resolveText
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TwoFaTokensScreenContent(
|
||||||
|
uiState: TwoFaTokensScreenState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tokenList: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.errorNotification?.let { notification ->
|
||||||
|
Text(
|
||||||
|
text = notification.resolveText(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.items.isEmpty()) {
|
||||||
|
Text(stringResource(R.string.two_fa_empty_state))
|
||||||
|
} else {
|
||||||
|
tokenList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TwoFaTokenListHeader(
|
||||||
|
issuer: String,
|
||||||
|
account: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.padding(top = 2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = issuer,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = account,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.github.nullptroma.wallenc.ui
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.navigation
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [33])
|
||||||
|
class WallencDeepLinksTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun matchesWallencViewIntent() {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.MAIN_URI_PATTERN))
|
||||||
|
assertTrue(intent.matchesWallencDeepLink())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsUnrelatedIntent() {
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN)
|
||||||
|
assertFalse(intent.matchesWallencDeepLink())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun matchesTasksAndSettingsHosts() {
|
||||||
|
assertTrue(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.TASKS_URI_PATTERN))
|
||||||
|
.matchesWallencDeepLink(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.SETTINGS_URI_PATTERN))
|
||||||
|
.matchesWallencDeepLink(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [33])
|
||||||
|
class OtpAuthUriParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesStandardTotpUri() {
|
||||||
|
val uri = "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&digits=6&period=30"
|
||||||
|
val parsed = parseOtpAuthTotpUri(uri)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("GitHub", parsed!!.issuer)
|
||||||
|
assertEquals("user@example.com", parsed.account)
|
||||||
|
assertEquals("JBSWY3DPEHPK3PXP", parsed.secret)
|
||||||
|
assertEquals(6, parsed.digits)
|
||||||
|
assertEquals(30, parsed.periodSeconds)
|
||||||
|
assertEquals("SHA1", parsed.algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsNonOtpauthScheme() {
|
||||||
|
assertNull(parseOtpAuthTotpUri("https://example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsMissingSecret() {
|
||||||
|
assertNull(parseOtpAuthTotpUri("otpauth://totp/Test:account"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||||
|
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
|
||||||
|
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [33])
|
||||||
|
class TaskPipelineViewModelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startTestTaskEnqueuesWork() = runBlocking {
|
||||||
|
val orchestrator = TaskOrchestrator(Dispatchers.Default)
|
||||||
|
val uiStrings = mockk<UiStringResolver>()
|
||||||
|
every { uiStrings.invoke(any<Int>(), any()) } returns "Test task"
|
||||||
|
every { uiStrings.invoke(any<Int>()) } returns "Test"
|
||||||
|
val viewModel = TaskPipelineViewModel(orchestrator, uiStrings)
|
||||||
|
|
||||||
|
viewModel.startTestTask(durationSec = 0, infinityIndeterminateProgress = false)
|
||||||
|
|
||||||
|
withTimeout(5_000) {
|
||||||
|
while (true) {
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.singleOrNull()
|
||||||
|
if (task?.state is TaskRunState.Completed) break
|
||||||
|
delay(25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val task = orchestrator.pipelineState.value.tasks.single()
|
||||||
|
assertTrue(task.state is TaskRunState.Completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.java.otp)
|
implementation(libs.java.otp)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
package com.github.nullptroma.wallenc.usecases
|
package com.github.nullptroma.wallenc.usecases
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
|
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
|
||||||
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class StorageDomainUseCasesTest {
|
class StorageDomainUseCasesTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun twoFaCrudWorksAndPersists() = runBlocking {
|
fun twoFaCrudWorksAndPersists() = runTest {
|
||||||
val storage = FakeStorage()
|
val storage = FakeStorage()
|
||||||
val useCase = ManageTwoFaTokensUseCase()
|
val useCase = ManageTwoFaTokensUseCase()
|
||||||
|
|
||||||
@@ -63,7 +40,7 @@ class StorageDomainUseCasesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun twoFaInvalidJsonFallsBackToEmptyList() = runBlocking {
|
fun twoFaInvalidJsonFallsBackToEmptyList() = runTest {
|
||||||
val storage = FakeStorage().apply {
|
val storage = FakeStorage().apply {
|
||||||
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
|
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
|
||||||
}
|
}
|
||||||
@@ -72,7 +49,7 @@ class StorageDomainUseCasesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun textSecretsCrudWorksWithOptionalLabels() = runBlocking {
|
fun textSecretsCrudWorksWithOptionalLabels() = runTest {
|
||||||
val storage = FakeStorage()
|
val storage = FakeStorage()
|
||||||
val useCase = ManageTextSecretsUseCase()
|
val useCase = ManageTextSecretsUseCase()
|
||||||
|
|
||||||
@@ -104,7 +81,7 @@ class StorageDomainUseCasesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun textSecretsInvalidJsonFallsBackToEmptyList() = runBlocking {
|
fun textSecretsInvalidJsonFallsBackToEmptyList() = runTest {
|
||||||
val storage = FakeStorage().apply {
|
val storage = FakeStorage().apply {
|
||||||
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
|
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
|
||||||
}
|
}
|
||||||
@@ -112,131 +89,3 @@ class StorageDomainUseCasesTest {
|
|||||||
assertTrue(useCase.observe(storage).first().isEmpty())
|
assertTrue(useCase.observe(storage).first().isEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeStorage : IStorage {
|
|
||||||
private val accessorImpl = FakeStorageAccessor()
|
|
||||||
|
|
||||||
override val uuid: UUID = UUID.randomUUID()
|
|
||||||
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
|
|
||||||
override val size: StateFlow<Long?> = MutableStateFlow(0L)
|
|
||||||
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
|
||||||
override val isEmpty: Flow<Boolean?> = flowOf(true)
|
|
||||||
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(FakeMetaInfo())
|
|
||||||
override val isVirtualStorage: Boolean = false
|
|
||||||
override val accessor: IStorageAccessor = accessorImpl
|
|
||||||
|
|
||||||
override suspend fun rename(newName: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit
|
|
||||||
|
|
||||||
override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit
|
|
||||||
|
|
||||||
fun setDomainFile(path: String, value: String) {
|
|
||||||
accessorImpl.dataFiles[path] = value.encodeToByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeMetaInfo : IStorageMetaInfo {
|
|
||||||
override val encInfo: StorageEncryptionInfo? = null
|
|
||||||
override val name: String = "Fake"
|
|
||||||
override val lastModified: Instant = Instant.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeStorageAccessor : IStorageAccessor {
|
|
||||||
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
|
||||||
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
|
||||||
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
|
|
||||||
|
|
||||||
override val size: StateFlow<Long?> = MutableStateFlow(0L)
|
|
||||||
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
|
||||||
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
|
|
||||||
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
|
|
||||||
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
|
|
||||||
|
|
||||||
override suspend fun getAllFiles(): List<IFile> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getFiles(path: String): List<IFile> = emptyList()
|
|
||||||
|
|
||||||
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
|
|
||||||
|
|
||||||
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
|
|
||||||
|
|
||||||
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = emptyFlow()
|
|
||||||
|
|
||||||
override suspend fun getFileInfo(path: String): IFile {
|
|
||||||
error("Not implemented in tests")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDirInfo(path: String): IDirectory {
|
|
||||||
error("Not implemented in tests")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setHidden(path: String, hidden: Boolean) = Unit
|
|
||||||
|
|
||||||
override suspend fun touchFile(path: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun touchDir(path: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun delete(path: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun openWrite(path: String): OutputStream {
|
|
||||||
return object : ByteArrayOutputStream() {
|
|
||||||
override fun close() {
|
|
||||||
dataFiles[path] = toByteArray()
|
|
||||||
_filesUpdates.tryEmit(
|
|
||||||
DataPage(
|
|
||||||
listOf(FakeFile(path)),
|
|
||||||
pageLength = 1,
|
|
||||||
pageIndex = 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun openRead(path: String): InputStream {
|
|
||||||
val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path")
|
|
||||||
return ByteArrayInputStream(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun moveToTrash(path: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun openReadSystemFile(name: String): InputStream {
|
|
||||||
val bytes = systemFiles[name] ?: ByteArray(0)
|
|
||||||
return ByteArrayInputStream(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun openWriteSystemFile(name: String): OutputStream {
|
|
||||||
return object : ByteArrayOutputStream() {
|
|
||||||
override fun close() {
|
|
||||||
systemFiles[name] = toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
|
|
||||||
|
|
||||||
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
|
|
||||||
|
|
||||||
override suspend fun readSyncLock(): StorageSyncLock? = null
|
|
||||||
|
|
||||||
override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = true
|
|
||||||
|
|
||||||
override suspend fun releaseSyncLock(holderId: String) = Unit
|
|
||||||
|
|
||||||
override suspend fun forceClearSyncLock() = Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeFile(path: String) : IFile {
|
|
||||||
override val metaInfo: IMetaInfo = object : IMetaInfo {
|
|
||||||
override val size: Long = 0L
|
|
||||||
override val isDeleted: Boolean = false
|
|
||||||
override val isHidden: Boolean = false
|
|
||||||
override val lastModified: Instant = Instant.now()
|
|
||||||
override val path: String = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
|
||||||
|
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
|
||||||
|
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageSyncGroupStore
|
||||||
|
import com.github.nullptroma.wallenc.usecases.fakes.FakeVaultsManager
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class StorageSyncEngineTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncAllGroupsReportsNoGroupsWhenEmpty() = runBlocking {
|
||||||
|
val labels = mutableListOf<TaskProgressLabel?>()
|
||||||
|
val engine = createEngine(storages = emptyList(), groups = emptyList())
|
||||||
|
engine.syncAllGroups { _, label -> labels.add(label) }
|
||||||
|
assertTrue(labels.any { it is TaskProgressLabel.SyncNoGroups })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncGroupCopiesFileFromSourceToTarget() = runBlocking {
|
||||||
|
val source = FakeStorage()
|
||||||
|
val target = FakeStorage()
|
||||||
|
val path = "shared/doc.txt"
|
||||||
|
val payload = "sync-payload".encodeToByteArray()
|
||||||
|
source.putFile(path, payload)
|
||||||
|
|
||||||
|
val entry = StorageSyncJournalEntry(
|
||||||
|
path = path,
|
||||||
|
operation = StorageSyncOperation.UPSERT,
|
||||||
|
revision = StorageSyncRevision(
|
||||||
|
sequence = 1L,
|
||||||
|
actorId = "actor-a",
|
||||||
|
createdAt = Instant.parse("2024-01-01T00:00:00Z"),
|
||||||
|
),
|
||||||
|
size = payload.size.toLong(),
|
||||||
|
originStorageUuid = source.uuid,
|
||||||
|
)
|
||||||
|
source.addSyncJournalEntry(entry)
|
||||||
|
|
||||||
|
val groupId = "group-1"
|
||||||
|
val group = StorageSyncGroup(
|
||||||
|
id = groupId,
|
||||||
|
storageUuids = setOf(source.uuid, target.uuid),
|
||||||
|
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||||
|
)
|
||||||
|
val labels = mutableListOf<TaskProgressLabel?>()
|
||||||
|
val engine = createEngine(
|
||||||
|
storages = listOf(source, target),
|
||||||
|
groups = listOf(group),
|
||||||
|
)
|
||||||
|
engine.syncGroup(groupId) { _, label -> labels.add(label) }
|
||||||
|
|
||||||
|
assertArrayEquals(payload, target.fileBytes(path))
|
||||||
|
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupCompleted })
|
||||||
|
assertTrue(target.syncJournalEntries().any { it.path == path })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncGroupSkippedWhenFewerThanTwoStorages() = runBlocking {
|
||||||
|
val only = FakeStorage()
|
||||||
|
val groupId = "solo"
|
||||||
|
val group = StorageSyncGroup(
|
||||||
|
id = groupId,
|
||||||
|
storageUuids = setOf(only.uuid),
|
||||||
|
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||||
|
)
|
||||||
|
val labels = mutableListOf<TaskProgressLabel?>()
|
||||||
|
val engine = createEngine(storages = listOf(only), groups = listOf(group))
|
||||||
|
engine.syncGroup(groupId) { _, label -> labels.add(label) }
|
||||||
|
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupSkippedTooFewStorages })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncGroupDeleteRemovesFileOnTarget() = runBlocking {
|
||||||
|
val source = FakeStorage()
|
||||||
|
val target = FakeStorage()
|
||||||
|
val path = "obsolete.bin"
|
||||||
|
target.putFile(path, "old".encodeToByteArray())
|
||||||
|
|
||||||
|
val entry = StorageSyncJournalEntry(
|
||||||
|
path = path,
|
||||||
|
operation = StorageSyncOperation.DELETE,
|
||||||
|
revision = StorageSyncRevision(
|
||||||
|
sequence = 2L,
|
||||||
|
actorId = "actor-b",
|
||||||
|
createdAt = Instant.parse("2024-06-01T00:00:00Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source.addSyncJournalEntry(entry)
|
||||||
|
|
||||||
|
val group = StorageSyncGroup(
|
||||||
|
id = "delete-group",
|
||||||
|
storageUuids = setOf(source.uuid, target.uuid),
|
||||||
|
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||||
|
)
|
||||||
|
val engine = createEngine(
|
||||||
|
storages = listOf(source, target),
|
||||||
|
groups = listOf(group),
|
||||||
|
)
|
||||||
|
engine.syncGroup(group.id) { _, _ -> }
|
||||||
|
|
||||||
|
assertNull(target.fileBytes(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncGroupStopsWhenLockCannotBeAcquired() = runBlocking {
|
||||||
|
val first = FakeStorage()
|
||||||
|
val second = FakeStorage().apply { setAcquireLockResult(false) }
|
||||||
|
val group = StorageSyncGroup(
|
||||||
|
id = "lock-fail",
|
||||||
|
storageUuids = setOf(first.uuid, second.uuid),
|
||||||
|
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||||
|
)
|
||||||
|
first.addSyncJournalEntry(
|
||||||
|
StorageSyncJournalEntry(
|
||||||
|
path = "a.txt",
|
||||||
|
operation = StorageSyncOperation.UPSERT,
|
||||||
|
revision = StorageSyncRevision(1L, "x", Instant.EPOCH),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val labels = mutableListOf<TaskProgressLabel?>()
|
||||||
|
val engine = createEngine(
|
||||||
|
storages = listOf(first, second),
|
||||||
|
groups = listOf(group),
|
||||||
|
)
|
||||||
|
engine.syncGroup(group.id) { _, label -> labels.add(label) }
|
||||||
|
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupLockFailed })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEngine(
|
||||||
|
storages: List<FakeStorage>,
|
||||||
|
groups: List<StorageSyncGroup>,
|
||||||
|
): StorageSyncEngine {
|
||||||
|
val vaultsManager = FakeVaultsManager(storages)
|
||||||
|
val findStorage = FindStorageUseCase(vaultsManager)
|
||||||
|
return StorageSyncEngine(
|
||||||
|
vaultsManager = vaultsManager,
|
||||||
|
groupStore = FakeStorageSyncGroupStore(groups),
|
||||||
|
findStorageUseCase = findStorage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
|
||||||
|
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class TwoFaTotpTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildTwoFaCodeStateMatchesJavaOtpForKnownSecret() {
|
||||||
|
val secretBase32 = "JBSWY3DPEHPK3PXP"
|
||||||
|
val token = TwoFaTokenRecord(
|
||||||
|
id = "1",
|
||||||
|
issuer = "Test",
|
||||||
|
account = "user",
|
||||||
|
secret = secretBase32,
|
||||||
|
digits = 6,
|
||||||
|
periodSeconds = 30,
|
||||||
|
algorithm = "SHA1",
|
||||||
|
notes = null,
|
||||||
|
)
|
||||||
|
val nowMillis = 1_700_000_000_000L
|
||||||
|
val state = buildTwoFaCodeState(token, nowMillis)
|
||||||
|
assertNotNull(state)
|
||||||
|
val expected = generateReferenceTotp(secretBase32, nowMillis, 6, 30, "HmacSHA1")
|
||||||
|
assertEquals(expected, state!!.code)
|
||||||
|
assertTrue(state.secondsUntilRefresh in 1..30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildTwoFaCodeStateReturnsNullForInvalidSecret() {
|
||||||
|
val token = TwoFaTokenRecord(
|
||||||
|
id = "1",
|
||||||
|
issuer = "Test",
|
||||||
|
account = "user",
|
||||||
|
secret = "!!!not-base32!!!",
|
||||||
|
digits = 6,
|
||||||
|
periodSeconds = 30,
|
||||||
|
algorithm = "SHA1",
|
||||||
|
notes = null,
|
||||||
|
)
|
||||||
|
assertEquals(null, buildTwoFaCodeState(token, System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateReferenceTotp(
|
||||||
|
secretBase32: String,
|
||||||
|
nowMillis: Long,
|
||||||
|
digits: Int,
|
||||||
|
periodSeconds: Int,
|
||||||
|
macAlgorithm: String,
|
||||||
|
): String {
|
||||||
|
val key = decodeBase32(secretBase32)
|
||||||
|
val generator = TimeBasedOneTimePasswordGenerator(
|
||||||
|
Duration.ofSeconds(periodSeconds.toLong()),
|
||||||
|
digits,
|
||||||
|
macAlgorithm,
|
||||||
|
)
|
||||||
|
val otp = generator.generateOneTimePassword(
|
||||||
|
SecretKeySpec(key, "RAW"),
|
||||||
|
Instant.ofEpochMilli(nowMillis),
|
||||||
|
)
|
||||||
|
return otp.toString().padStart(digits, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBase32(input: String): ByteArray {
|
||||||
|
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||||
|
val clean = input.uppercase().replace(" ", "").replace("=", "")
|
||||||
|
var buffer = 0
|
||||||
|
var bitsLeft = 0
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
for (ch in clean) {
|
||||||
|
val value = alphabet.indexOf(ch)
|
||||||
|
buffer = (buffer shl 5) or value
|
||||||
|
bitsLeft += 5
|
||||||
|
if (bitsLeft >= 8) {
|
||||||
|
out.add(((buffer shr (bitsLeft - 8)) and 0xFF).toByte())
|
||||||
|
bitsLeft -= 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases.fakes
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class FakeStorage(
|
||||||
|
override val uuid: UUID = UUID.randomUUID(),
|
||||||
|
private val accessorImpl: FakeStorageAccessor = FakeStorageAccessor(),
|
||||||
|
private val meta: FakeMetaInfo = FakeMetaInfo(),
|
||||||
|
) : IStorage {
|
||||||
|
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
override val size: StateFlow<Long?> = MutableStateFlow(0L)
|
||||||
|
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
||||||
|
override val isEmpty: Flow<Boolean?> = flowOf(true)
|
||||||
|
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta)
|
||||||
|
override val isVirtualStorage: Boolean = false
|
||||||
|
override val accessor: IStorageAccessor = accessorImpl
|
||||||
|
|
||||||
|
override suspend fun rename(newName: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit
|
||||||
|
|
||||||
|
override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit
|
||||||
|
|
||||||
|
fun setDomainFile(path: String, value: String) {
|
||||||
|
putFile(path, value.encodeToByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putFile(path: String, bytes: ByteArray) {
|
||||||
|
accessorImpl.dataFiles[path] = bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileBytes(path: String): ByteArray? = accessorImpl.dataFiles[path]
|
||||||
|
|
||||||
|
fun addSyncJournalEntry(entry: StorageSyncJournalEntry) {
|
||||||
|
accessorImpl.syncJournal.add(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncJournalEntries(): List<StorageSyncJournalEntry> = accessorImpl.syncJournal.toList()
|
||||||
|
|
||||||
|
fun setAcquireLockResult(result: Boolean) {
|
||||||
|
accessorImpl.acquireLockResult = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMetaInfo(
|
||||||
|
override val encInfo: StorageEncryptionInfo? = null,
|
||||||
|
override val name: String = "Fake",
|
||||||
|
override val lastModified: Instant = Instant.now(),
|
||||||
|
) : IStorageMetaInfo
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases.fakes
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class FakeStorageAccessor : IStorageAccessor {
|
||||||
|
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
||||||
|
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
||||||
|
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
|
var syncJournal: MutableList<StorageSyncJournalEntry> = mutableListOf()
|
||||||
|
var syncLock: StorageSyncLock? = null
|
||||||
|
var acquireLockResult: Boolean = true
|
||||||
|
|
||||||
|
override val size: StateFlow<Long?> = MutableStateFlow(0L)
|
||||||
|
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
||||||
|
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
|
||||||
|
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
|
||||||
|
|
||||||
|
override suspend fun getAllFiles(): List<IFile> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getFiles(path: String): List<IFile> = emptyList()
|
||||||
|
|
||||||
|
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
|
||||||
|
|
||||||
|
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
|
||||||
|
|
||||||
|
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = emptyFlow()
|
||||||
|
|
||||||
|
override suspend fun getFileInfo(path: String): IFile {
|
||||||
|
error("Not implemented in tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDirInfo(path: String): IDirectory {
|
||||||
|
error("Not implemented in tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setHidden(path: String, hidden: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun touchFile(path: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun touchDir(path: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun delete(path: String) {
|
||||||
|
dataFiles.remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun openWrite(path: String): OutputStream {
|
||||||
|
return object : ByteArrayOutputStream() {
|
||||||
|
override fun close() {
|
||||||
|
dataFiles[path] = toByteArray()
|
||||||
|
_filesUpdates.tryEmit(
|
||||||
|
DataPage(
|
||||||
|
listOf(FakeFile(path)),
|
||||||
|
pageLength = 1,
|
||||||
|
pageIndex = 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun openRead(path: String): InputStream {
|
||||||
|
val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path")
|
||||||
|
return ByteArrayInputStream(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveToTrash(path: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun openReadSystemFile(name: String): InputStream {
|
||||||
|
val bytes = systemFiles[name] ?: ByteArray(0)
|
||||||
|
return ByteArrayInputStream(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun openWriteSystemFile(name: String): OutputStream {
|
||||||
|
return object : ByteArrayOutputStream() {
|
||||||
|
override fun close() {
|
||||||
|
systemFiles[name] = toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = syncJournal.toList()
|
||||||
|
|
||||||
|
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) {
|
||||||
|
syncJournal.addAll(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) {
|
||||||
|
syncJournal.clear()
|
||||||
|
syncJournal.addAll(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readSyncLock(): StorageSyncLock? = syncLock
|
||||||
|
|
||||||
|
override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean {
|
||||||
|
if (!acquireLockResult) return false
|
||||||
|
syncLock = StorageSyncLock(holderId, leaseUntil, Instant.now())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun releaseSyncLock(holderId: String) {
|
||||||
|
if (syncLock?.holderId == holderId) {
|
||||||
|
syncLock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun forceClearSyncLock() {
|
||||||
|
syncLock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFile(path: String) : IFile {
|
||||||
|
override val metaInfo: IMetaInfo = object : IMetaInfo {
|
||||||
|
override val size: Long = 0L
|
||||||
|
override val isDeleted: Boolean = false
|
||||||
|
override val isHidden: Boolean = false
|
||||||
|
override val lastModified: Instant = Instant.now()
|
||||||
|
override val path: String = path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases.fakes
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
|
||||||
|
|
||||||
|
class FakeStorageSyncGroupStore(
|
||||||
|
private var groups: List<StorageSyncGroup> = emptyList(),
|
||||||
|
) : IStorageSyncGroupStore {
|
||||||
|
override suspend fun getGroups(): List<StorageSyncGroup> = groups
|
||||||
|
|
||||||
|
override suspend fun putGroup(group: StorageSyncGroup) {
|
||||||
|
groups = groups.filterNot { it.id == group.id } + group
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeGroup(groupId: String) {
|
||||||
|
groups = groups.filterNot { it.id == groupId }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.github.nullptroma.wallenc.usecases.fakes
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IVault
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class FakeVaultsManager(
|
||||||
|
storages: List<IStorage>,
|
||||||
|
) : IVaultsManager {
|
||||||
|
override val vaults: StateFlow<List<IVault>> = MutableStateFlow(emptyList())
|
||||||
|
override val allStorages: StateFlow<List<IStorage>> = MutableStateFlow(storages)
|
||||||
|
override val unlockManager: IUnlockManager = FakeUnlockManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeUnlockManager : IUnlockManager {
|
||||||
|
override val openedStorages: StateFlow<Map<UUID, IStorage>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override fun getOpenedStorageKey(uuid: UUID): EncryptKey? = null
|
||||||
|
|
||||||
|
override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage
|
||||||
|
|
||||||
|
override suspend fun close(storage: IStorage) = Unit
|
||||||
|
|
||||||
|
override suspend fun close(uuid: UUID) = Unit
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user