diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 586180a..635b2e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,8 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7537efd..59a7612 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt index 8095d1c..d1ee0b0 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/MainActivity.kt @@ -9,30 +9,37 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import com.github.nullptroma.wallenc.app.auth.YandexSignInService import com.github.nullptroma.wallenc.presentation.WallencUi import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var yandexSignInService: YandexSignInService + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + yandexSignInService.registerWith(this) enableEdgeToEdge() requestNotificationPermissionIfNeeded() Timber.plant(Timber.DebugTree()) -// val sdk = YandexAuthSdk.create(YandexAuthOptions(applicationContext, true)) -// val launcher = -// registerForActivityResult(sdk.contract) { result -> handleResult(result) } -// val loginOptions = YandexAuthLoginOptions(LoginType.CHROME_TAB) setContent { WallencUi() } } + override fun onDestroy() { + yandexSignInService.unregister(this) + super.onDestroy() + } + private fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val granted = ContextCompat.checkSelfPermission( @@ -50,12 +57,4 @@ class MainActivity : ComponentActivity() { companion object { private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100 } - -// private fun handleResult(result: YandexAuthResult) { -// when (result) { -// is YandexAuthResult.Success -> Toast.makeText(applicationContext, "Success: ${result.token}", Toast.LENGTH_SHORT).show() -// is YandexAuthResult.Failure -> Toast.makeText(applicationContext, "Success: ${result.exception}", Toast.LENGTH_SHORT).show() -// YandexAuthResult.Cancelled -> Toast.makeText(applicationContext, "Cancel", Toast.LENGTH_SHORT).show() -// } -// } } diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt new file mode 100644 index 0000000..e50c076 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt @@ -0,0 +1,83 @@ +package com.github.nullptroma.wallenc.app.auth + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import com.github.nullptroma.wallenc.domain.auth.RemoteYandexAuthResult +import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher +import com.yandex.authsdk.YandexAuthLoginOptions +import com.yandex.authsdk.YandexAuthOptions +import com.yandex.authsdk.YandexAuthResult +import com.yandex.authsdk.YandexAuthSdk +import com.yandex.authsdk.internal.strategy.LoginType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Императивный вход через Yandex Auth SDK: [registerWith] в [ComponentActivity.onCreate], + * [unregister] в [ComponentActivity.onDestroy], чтобы синглтон не держал [ActivityResultLauncher] + * дольше жизни Activity. + * + * При смене конфигурации новая Activity может вызвать [registerWith] до [unregister] старой — + * тогда владелец и launcher обновляются; [unregister] для уже неактуального экземпляра — no-op. + */ +@Singleton +class YandexSignInService @Inject constructor( + @ApplicationContext appContext: Context, +) : RemoteYandexSignInLauncher { + + private val sdk = YandexAuthSdk.create(YandexAuthOptions(appContext, true)) + + private var launcher: ActivityResultLauncher? = null + + private var registrationOwner: ComponentActivity? = null + + @Volatile + private var pending: ((RemoteYandexAuthResult) -> Unit)? = null + + fun registerWith(activity: ComponentActivity) { + if (registrationOwner === activity && launcher != null) return + launcher = activity.registerForActivityResult(sdk.contract) { result -> + val mapped = mapYandexResult(result) + val cb = synchronized(this) { + val p = pending + pending = null + p + } + cb?.invoke(mapped) + } + registrationOwner = activity + } + + fun unregister(activity: ComponentActivity) { + if (registrationOwner !== activity) return + launcher = null + registrationOwner = null + val cb = synchronized(this) { + val p = pending + pending = null + p + } + cb?.invoke(RemoteYandexAuthResult.Cancelled) + } + + override fun launch(onResult: (RemoteYandexAuthResult) -> Unit) { + val l = launcher + ?: error("YandexSignInService: call registerWith(activity) from MainActivity.onCreate first") + synchronized(this) { + pending = onResult + } + l.launch(YandexAuthLoginOptions(LoginType.WEBVIEW)) + } + + private fun mapYandexResult(result: YandexAuthResult): RemoteYandexAuthResult = when (result) { + is YandexAuthResult.Success -> + RemoteYandexAuthResult.Success(result.token.value) + is YandexAuthResult.Failure -> + RemoteYandexAuthResult.Failure( + result.exception.message ?: result.exception.toString(), + ) + YandexAuthResult.Cancelled -> RemoteYandexAuthResult.Cancelled + } +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt new file mode 100644 index 0000000..7f78c45 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt @@ -0,0 +1,20 @@ +package com.github.nullptroma.wallenc.app.di.modules.auth + +import com.github.nullptroma.wallenc.app.auth.YandexSignInService +import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class YandexAuthModule { + + @Binds + @Singleton + abstract fun bindRemoteYandexSignInLauncher( + impl: YandexSignInService, + ): RemoteYandexSignInLauncher +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt index a998fd4..4574c82 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt @@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.data.db.RoomFactory import com.github.nullptroma.wallenc.data.db.app.IAppDb import com.github.nullptroma.wallenc.data.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.data.db.app.dao.YandexAccountDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -32,6 +33,12 @@ class RoomModule { return database.storageMetaInfoDao } + @Provides + @Singleton + fun provideYandexAccountDao(database: IAppDb): YandexAccountDao { + return database.yandexAccountDao + } + @Provides @Singleton fun provideAppDb( diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt index cc696dc..7804aed 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt @@ -4,6 +4,9 @@ import android.content.Context import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher import com.github.nullptroma.wallenc.data.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.data.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.data.network.YandexUserInfoApi +import com.github.nullptroma.wallenc.data.network.YandexUserInfoApiFactory import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository import com.github.nullptroma.wallenc.data.tasks.TaskOrchestrator @@ -28,14 +31,20 @@ class SingletonModule { @IoDispatcher ioDispatcher: CoroutineDispatcher, ): ITaskOrchestrator = TaskOrchestrator(ioDispatcher) + @Provides + @Singleton + fun provideYandexUserInfoApi(): YandexUserInfoApi = YandexUserInfoApiFactory.create() + @Provides @Singleton fun provideVaultsManager( @IoDispatcher ioDispatcher: CoroutineDispatcher, @ApplicationContext context: Context, keyRepo: StorageKeyMapRepository, + yandexAccountDao: YandexAccountDao, + yandexUserInfoApi: YandexUserInfoApi, ): IVaultsManager { - return VaultsManager(ioDispatcher, context, keyRepo) + return VaultsManager(ioDispatcher, context, keyRepo, yandexAccountDao, yandexUserInfoApi) } @Provides diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt index ed3e40f..d160280 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt @@ -4,16 +4,24 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.github.nullptroma.wallenc.data.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.data.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.data.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.data.db.app.model.DbStorageKeyMap import com.github.nullptroma.wallenc.data.db.app.model.DbStorageMetaInfo +import com.github.nullptroma.wallenc.data.db.app.model.DbYandexAccount interface IAppDb { val storageKeyMapDao: StorageKeyMapDao val storageMetaInfoDao: StorageMetaInfoDao + val yandexAccountDao: YandexAccountDao } -@Database(entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class], version = 3, exportSchema = false) +@Database( + entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class], + version = 4, + exportSchema = false, +) abstract class AppDb : IAppDb, RoomDatabase() { abstract override val storageKeyMapDao: StorageKeyMapDao abstract override val storageMetaInfoDao: StorageMetaInfoDao + abstract override val yandexAccountDao: YandexAccountDao } \ No newline at end of file diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt new file mode 100644 index 0000000..28b9693 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt @@ -0,0 +1,27 @@ +package com.github.nullptroma.wallenc.data.db.app.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.github.nullptroma.wallenc.data.db.app.model.DbYandexAccount +import kotlinx.coroutines.flow.Flow + +@Dao +interface YandexAccountDao { + @Query("SELECT * FROM yandex_accounts WHERE yandexUserId = :id LIMIT 1") + suspend fun getByYandexUserId(id: String): DbYandexAccount? + + @Insert + suspend fun insert(account: DbYandexAccount) + + @Query( + "UPDATE yandex_accounts SET oauthToken = :token, email = :email WHERE vaultUuid = :vaultUuid", + ) + suspend fun updateCredentials(vaultUuid: String, email: String, token: String) + + @Query("SELECT * FROM yandex_accounts ORDER BY email COLLATE NOCASE ASC") + fun observeAll(): Flow> + + @Query("DELETE FROM yandex_accounts WHERE vaultUuid = :vaultUuid") + suspend fun deleteByVaultUuid(vaultUuid: String) +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbYandexAccount.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbYandexAccount.kt new file mode 100644 index 0000000..4ea484d --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbYandexAccount.kt @@ -0,0 +1,16 @@ +package com.github.nullptroma.wallenc.data.db.app.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "yandex_accounts", + indices = [Index(value = ["yandexUserId"], unique = true)], +) +data class DbYandexAccount( + @PrimaryKey val vaultUuid: String, + val yandexUserId: String, + val email: String, + val oauthToken: String, +) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApi.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApi.kt new file mode 100644 index 0000000..0c55e15 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApi.kt @@ -0,0 +1,13 @@ +package com.github.nullptroma.wallenc.data.network + +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +interface YandexUserInfoApi { + @GET("info") + suspend fun userInfo( + @Query("format") format: String, + @Header("Authorization") authorization: String, + ): YandexUserInfoDto +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApiFactory.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApiFactory.kt new file mode 100644 index 0000000..29e0a03 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoApiFactory.kt @@ -0,0 +1,17 @@ +package com.github.nullptroma.wallenc.data.network + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory + +object YandexUserInfoApiFactory { + fun create(): YandexUserInfoApi { + val retrofit = Retrofit.Builder() + .baseUrl("https://login.yandex.ru/") + .addConverterFactory( + JacksonConverterFactory.create(jacksonObjectMapper().findAndRegisterModules()), + ) + .build() + return retrofit.create(YandexUserInfoApi::class.java) + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoDto.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoDto.kt new file mode 100644 index 0000000..9ebebf7 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/YandexUserInfoDto.kt @@ -0,0 +1,11 @@ +package com.github.nullptroma.wallenc.data.network + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class YandexUserInfoDto( + val id: String, + val login: String, + @JsonProperty("default_email") val defaultEmail: String? = null, +) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt index bb61cff..6b665e0 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -33,7 +33,7 @@ class UnlockManager( init { CoroutineScope(ioDispatcher).launch { - vaultsManager.allStorages.collectLatest { + vaultsManager.allStorages.collect { mutex.withLock { val allKeys = keymapRepository.getAll() val keysToRemove = mutableListOf() diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt index 1d1308c..43ca755 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt @@ -1,33 +1,84 @@ package com.github.nullptroma.wallenc.data.vaults import android.content.Context +import com.github.nullptroma.wallenc.data.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.data.db.app.model.DbYandexAccount import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository +import com.github.nullptroma.wallenc.data.network.YandexUserInfoApi import com.github.nullptroma.wallenc.data.storages.UnlockManager 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.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import java.util.UUID + +class VaultsManager( + private val ioDispatcher: CoroutineDispatcher, + context: Context, + keyRepo: StorageKeyMapRepository, + private val yandexAccountDao: YandexAccountDao, + private val yandexUserInfoApi: YandexUserInfoApi, +) : IVaultsManager { + private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) -class VaultsManager(ioDispatcher: CoroutineDispatcher, context: Context, keyRepo: StorageKeyMapRepository) : IVaultsManager { override val localVault = LocalVault(ioDispatcher, context) + + // До unlockManager: UnlockManager в init подписывается на allStorages. + override val allStorages: StateFlow> = localVault.storages + override val unlockManager: IUnlockManager = UnlockManager( keymapRepository = keyRepo, ioDispatcher = ioDispatcher, vaultsManager = this ) - override val remoteVaults: StateFlow> - get() = TODO("Not yet implemented") - override val allStorages: StateFlow> - get() = localVault.storages - override val allVaults: StateFlow> - get() = MutableStateFlow(listOf(localVault)) + private val _remoteVaults = yandexAccountDao.observeAll() + .map { rows -> + rows.map { row -> + YandexVault( + uuid = UUID.fromString(row.vaultUuid), + accountEmail = row.email, + oauthToken = row.oauthToken, + ) + } + } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) - override fun addYandexVault(email: String, token: String) { - TODO("Not yet implemented") + override val remoteVaults: StateFlow> = _remoteVaults + + override val allVaults: StateFlow> = _remoteVaults + .map { listOf(localVault) + it } + .stateIn(scope, SharingStarted.Eagerly, listOf(localVault)) + + override suspend fun addYandexVault(accessToken: String) = withContext(ioDispatcher) { + val info = yandexUserInfoApi.userInfo("json", "OAuth $accessToken") + val email = info.defaultEmail?.takeIf { it.isNotBlank() } + ?: "${info.login}@yandex.ru" + val existing = yandexAccountDao.getByYandexUserId(info.id) + val vaultUuid = existing?.vaultUuid ?: UUID.randomUUID().toString() + if (existing != null) { + yandexAccountDao.updateCredentials(vaultUuid, email, accessToken) + } else { + yandexAccountDao.insert( + DbYandexAccount( + vaultUuid = vaultUuid, + yandexUserId = info.id, + email = email, + oauthToken = accessToken, + ) + ) + } } -} \ No newline at end of file + override suspend fun removeRemoteVault(vaultUuid: UUID) = withContext(ioDispatcher) { + yandexAccountDao.deleteByVaultUuid(vaultUuid.toString()) + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt new file mode 100644 index 0000000..7746148 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt @@ -0,0 +1,44 @@ +package com.github.nullptroma.wallenc.data.vaults + +import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo +import com.github.nullptroma.wallenc.domain.enums.VaultType +import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IYandexVault +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +/** + * Удалённый vault Яндекс: OAuth и профиль есть; Yandex.Disk и хранилища — заглушки. + */ +class YandexVault( + override val uuid: UUID, + override val accountEmail: String, + @Suppress("unused") val oauthToken: String, +) : IYandexVault { + override val type: VaultType = VaultType.YANDEX + + private val _storages = MutableStateFlow>(emptyList()) + override val storages: StateFlow?> = _storages + + private val _isAvailable = MutableStateFlow(true) + override val isAvailable: StateFlow = _isAvailable + + private val _totalSpace = MutableStateFlow(null) + override val totalSpace: StateFlow = _totalSpace + + private val _availableSpace = MutableStateFlow(null) + override val availableSpace: StateFlow = _availableSpace + + override suspend fun createStorage(): IStorage { + throw UnsupportedOperationException("Yandex.Disk ещё не подключён") + } + + override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage { + throw UnsupportedOperationException("Yandex.Disk ещё не подключён") + } + + override suspend fun remove(storage: IStorage) { + // заглушка до интеграции API Диска + } +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt new file mode 100644 index 0000000..88d533f --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt @@ -0,0 +1,17 @@ +package com.github.nullptroma.wallenc.domain.auth + +/** + * Результат входа через Яндекс (без зависимости от Yandex SDK). + */ +sealed interface RemoteYandexAuthResult { + data class Success(val accessToken: String) : RemoteYandexAuthResult + data class Failure(val message: String) : RemoteYandexAuthResult + data object Cancelled : RemoteYandexAuthResult +} + +/** + * Запуск OAuth Яндекса. Реализация только в модуле app (Yandex Auth SDK). + */ +fun interface RemoteYandexSignInLauncher { + fun launch(onResult: (RemoteYandexAuthResult) -> Unit) +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt index 514d54f..4641534 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.domain.interfaces import kotlinx.coroutines.flow.StateFlow +import java.util.UUID interface IVaultsManager { val localVault: IVault @@ -8,5 +9,7 @@ interface IVaultsManager { val remoteVaults: StateFlow> val allStorages: StateFlow> val allVaults: StateFlow> - fun addYandexVault(email: String, token: String) + suspend fun addYandexVault(accessToken: String) + + suspend fun removeRemoteVault(vaultUuid: UUID) } \ No newline at end of file diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt new file mode 100644 index 0000000..9a18f52 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.domain.interfaces + +/** + * Удалённый vault Яндекс с привязанным аккаунтом (почта для UI). + */ +interface IYandexVault : IVault { + val accountEmail: String +} diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt index 8c95ff3..a6ccc1c 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt @@ -19,7 +19,7 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.presentation.ViewModelBase import com.github.nullptroma.wallenc.presentation.extensions.toPrintable import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -70,26 +70,24 @@ class LocalVaultViewModel @Inject constructor( private fun collectFlows() { viewModelScope.launch { manageLocalVaultUseCase.localStorages.combine(getOpenedStoragesUseCase.openedStorages) { local, opened -> - if(local == null) - return@combine null + if (local == null) { + return@combine Pair(true, emptyList>()) + } val list = mutableListOf>() for (storage in local) { var tree = Tree(storage) list.add(tree) - while(opened.containsKey(tree.value.uuid)) { + while (opened.containsKey(tree.value.uuid)) { val child = opened.getValue(tree.value.uuid) val nextTree = Tree(child) tree.children = listOf(nextTree) tree = nextTree } } - return@combine list - }.collectLatest { - isLoading = it == null - val newState = state.value.copy( - storagesList = it ?: listOf() - ) - updateState(newState) + return@combine Pair(false, list) + }.collect { (loading, trees) -> + isLoading = loading + updateState(state.value.copy(storagesList = trees)) } } } diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt index 835e9d0..c5a79ca 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt @@ -1,15 +1,241 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes -import androidx.compose.material3.ExperimentalMaterial3Api +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.nullptroma.wallenc.domain.auth.RemoteYandexAuthResult +import com.github.nullptroma.wallenc.domain.enums.VaultType +import com.github.nullptroma.wallenc.presentation.R - -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun RemoteVaultsScreen(modifier: Modifier = Modifier, - viewModel: RemoteVaultsViewModel = hiltViewModel()) { - Text("Remote vault screen", modifier = modifier) -} \ No newline at end of file +fun RemoteVaultsScreen( + modifier: Modifier = Modifier, + viewModel: RemoteVaultsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + Box { + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0.dp), + floatingActionButton = { + FloatingActionButton( + onClick = { + if (!uiState.isBusy) viewModel.setAddChoiceVisible(true) + }, + ) { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(R.string.remote_vaults_add_cd), + ) + } + }, + ) { innerPadding -> + if (uiState.vaults.isEmpty()) { + Text( + text = stringResource(R.string.remote_vaults_empty_hint), + modifier = Modifier + .padding(innerPadding) + .padding(24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(uiState.vaults, key = { it.uuid }) { item -> + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = when (item.type) { + VaultType.YANDEX -> + stringResource(R.string.remote_vault_type_yandex) + else -> item.type.name + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { viewModel.requestDeleteVault(item) }, + enabled = !uiState.isBusy, + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.remote_vault_delete_cd), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } + + if (uiState.isBusy) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp), + ) + } + } + + if (uiState.addChoiceVisible) { + Dialog( + onDismissRequest = { if (!uiState.isBusy) viewModel.setAddChoiceVisible(false) }, + ) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.remote_vaults_add_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(20.dp)) + FilledTonalButton( + onClick = { + viewModel.setAddChoiceVisible(false) + viewModel.yandexSignIn.launch { outcome -> + when (outcome) { + is RemoteYandexAuthResult.Success -> + viewModel.onYandexAuthSuccess(outcome.accessToken) + is RemoteYandexAuthResult.Failure -> + Toast.makeText(context, outcome.message, Toast.LENGTH_LONG) + .show() + RemoteYandexAuthResult.Cancelled -> { } + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isBusy, + ) { + Text(stringResource(R.string.remote_vaults_provider_yandex)) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = stringResource(R.string.remote_vaults_add_cancel), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clickable( + enabled = !uiState.isBusy, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { viewModel.setAddChoiceVisible(false) } + .padding(vertical = 8.dp, horizontal = 4.dp), + ) + } + } + } + } + } + + uiState.vaultPendingDelete?.let { pending -> + AlertDialog( + onDismissRequest = { if (!uiState.isBusy) viewModel.dismissDeleteVault() }, + title = { + Text(stringResource(R.string.remote_vault_remove_title)) + }, + text = { + Text(stringResource(R.string.remote_vault_remove_message, pending.label)) + }, + confirmButton = { + TextButton( + onClick = { viewModel.confirmDeleteVault() }, + enabled = !uiState.isBusy, + ) { + Text(stringResource(R.string.remove)) + } + }, + dismissButton = { + TextButton( + onClick = { viewModel.dismissDeleteVault() }, + enabled = !uiState.isBusy, + ) { + Text(stringResource(R.string.remote_vaults_add_cancel)) + } + }, + ) + } +} diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt index 3794eb6..250922e 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt @@ -1,3 +1,18 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes -class RemoteVaultsScreenState \ No newline at end of file +import com.github.nullptroma.wallenc.domain.enums.VaultType +import java.util.UUID + +data class RemoteVaultListItem( + val uuid: UUID, + val type: VaultType, + val label: String, +) + +data class RemoteVaultsScreenState( + val vaults: List = emptyList(), + val isBusy: Boolean = false, + val addChoiceVisible: Boolean = false, + /** Карточка, для которой показан диалог удаления */ + val vaultPendingDelete: RemoteVaultListItem? = null, +) \ No newline at end of file diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt index 3b51507..d104350 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt @@ -1,9 +1,84 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes +import androidx.lifecycle.viewModelScope +import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher +import com.github.nullptroma.wallenc.domain.interfaces.IYandexVault +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.presentation.ViewModelBase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class RemoteVaultsViewModel @Inject constructor() : - ViewModelBase(RemoteVaultsScreenState()) \ No newline at end of file +class RemoteVaultsViewModel @Inject constructor( + private val vaultsManager: IVaultsManager, + val yandexSignIn: RemoteYandexSignInLauncher, +) : ViewModelBase(RemoteVaultsScreenState()) { + + val uiState = combine( + vaultsManager.remoteVaults, + state, + ) { remotes, base -> + base.copy( + vaults = remotes.map { v -> + val label = when (v) { + is IYandexVault -> v.accountEmail + else -> v.uuid.toString() + } + RemoteVaultListItem( + uuid = v.uuid, + type = v.type, + label = label, + ) + }, + ) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + RemoteVaultsScreenState(), + ) + + fun setAddChoiceVisible(visible: Boolean) { + updateState(state.value.copy(addChoiceVisible = visible)) + } + + fun setBusy(busy: Boolean) { + updateState(state.value.copy(isBusy = busy)) + } + + fun onYandexAuthSuccess(accessToken: String) { + viewModelScope.launch { + setBusy(true) + try { + vaultsManager.addYandexVault(accessToken) + } finally { + setBusy(false) + setAddChoiceVisible(false) + } + } + } + + fun requestDeleteVault(item: RemoteVaultListItem) { + updateState(state.value.copy(vaultPendingDelete = item)) + } + + fun dismissDeleteVault() { + updateState(state.value.copy(vaultPendingDelete = null)) + } + + fun confirmDeleteVault() { + val pending = state.value.vaultPendingDelete ?: return + viewModelScope.launch { + setBusy(true) + try { + vaultsManager.removeRemoteVault(pending.uuid) + } finally { + setBusy(false) + dismissDeleteVault() + } + } + } +} diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 83c5c4f..658181a 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -34,4 +34,15 @@ Cancelled Failed: %1$s + Add remote vault + No remote vaults yet. Tap + to add Yandex. + Add vault + Choose provider: + Yandex + Cancel + Yandex + Remove remote vault + Remove remote vault? + Remove \"%1$s\" from this device? The account data on the server is not deleted. + \ No newline at end of file