kotlin refacto mobile app

This commit is contained in:
m
2026-09-11 12:40:50 +02:00
parent 6666c5b469
commit b2d8f3702c
53 changed files with 2132 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
val apiBaseUrl: String = providers.gradleProperty("VAULTDROP_API_BASE_URL")
.getOrElse("http://10.0.2.2:8080/api/v1")
android {
namespace = "com.vaultdrop.mobile"
compileSdk = 35
defaultConfig {
applicationId = "com.vaultdrop.mobile"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
implementation(libs.androidx.security.crypto)
implementation(libs.timber)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Project-specific ProGuard rules (R8).
# Rien pour la verticale minimale.
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".VaultDropApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.VaultDrop">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.VaultDrop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,23 @@
package com.vaultdrop.mobile
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.vaultdrop.mobile.ui.navigation.VaultDropApp
import com.vaultdrop.mobile.ui.theme.VaultDropTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
VaultDropTheme {
VaultDropApp()
}
}
}
}
@@ -0,0 +1,16 @@
package com.vaultdrop.mobile
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class VaultDropApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
}
@@ -0,0 +1,63 @@
package com.vaultdrop.mobile.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.vaultdrop.mobile.data.remote.dto.UserDto
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persistance chiffrée du token paseto et du profil compte (Keystore + AES).
* Miroir de `services/secureStore.ts` (expo-secure-store). Le token n'est
* JAMAIS écrit en clair dans SQLite.
*/
@Singleton
class SecureTokenStore @Inject constructor(
@ApplicationContext context: Context,
moshi: Moshi,
) {
private val userAdapter: JsonAdapter<UserDto> = moshi.adapter(UserDto::class.java)
private val prefs: SharedPreferences = run {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
FILE_NAME,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun getUser(): UserDto? {
val raw = prefs.getString(KEY_ACCOUNT, null) ?: return null
return runCatching { userAdapter.fromJson(raw) }
.getOrNull()
?.takeIf { it.id.isNotEmpty() && it.username.isNotEmpty() }
}
fun save(token: String, user: UserDto) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_ACCOUNT, userAdapter.toJson(user))
.apply()
}
fun clear() {
prefs.edit().remove(KEY_TOKEN).remove(KEY_ACCOUNT).apply()
}
companion object {
private const val FILE_NAME = "vaultdrop_secure"
private const val KEY_TOKEN = "vaultdrop.auth_token"
private const val KEY_ACCOUNT = "vaultdrop.account"
}
}
@@ -0,0 +1,46 @@
package com.vaultdrop.mobile.auth
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import com.vaultdrop.mobile.domain.ActiveUserStore
import com.vaultdrop.mobile.domain.DeviceIdentity
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestration de la session (restauration/sauvegarde/purge) : relie le store
* sécurisé, le miroir `active_user_id` et le token en mémoire lu par
* `AuthInterceptor`.
*/
@Singleton
class SessionManager @Inject constructor(
private val secureTokenStore: SecureTokenStore,
private val activeUserStore: ActiveUserStore,
private val tokenProvider: TokenProvider,
) {
/** Restaure la session persistée (si présente), sinon null. */
suspend fun restore(): AuthSession? {
val token = secureTokenStore.getToken() ?: return null
val user = secureTokenStore.getUser() ?: return null
tokenProvider.current = token
return AuthSession(token = token, user = user)
}
suspend fun save(token: String, user: com.vaultdrop.mobile.data.remote.dto.UserDto) {
secureTokenStore.save(token, user)
activeUserStore.set(user.id)
tokenProvider.current = token
}
suspend fun clear() {
secureTokenStore.clear()
activeUserStore.clear()
tokenProvider.current = null
}
}
data class AuthSession(
val token: String,
val user: com.vaultdrop.mobile.data.remote.dto.UserDto,
)
@@ -0,0 +1,16 @@
package com.vaultdrop.mobile.auth
import javax.inject.Inject
import javax.inject.Singleton
/**
* Fournisseur du token paseto en mémoire. Remplacé plus tard par une source
* persistante (EncryptedSharedPreferences / Keystore) une fois le login intégré.
* `null` = mode local.
*/
@Singleton
class TokenProvider @Inject constructor() {
@Volatile
var current: String? = null
}
@@ -0,0 +1,23 @@
package com.vaultdrop.mobile.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
/*
* DB SQLite locale, `dot.db` (même nom que la version Expo).
* v1: folders ; v2: user_preferences.
*/
@Database(
entities = [FolderEntity::class, UserPreferenceEntity::class],
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun folderDao(): FolderDao
abstract fun userPreferenceDao(): UserPreferenceDao
}
@@ -0,0 +1,43 @@
package com.vaultdrop.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface FolderDao {
@Query("SELECT * FROM folders ORDER BY name ASC")
fun observeAll(): Flow<List<FolderEntity>>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
suspend fun getRootFolders(): List<FolderEntity>
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC")
suspend fun getByParent(parentResourceId: String): List<FolderEntity>
@Query("SELECT * FROM folders WHERE resource_id = :resourceId LIMIT 1")
suspend fun getByResourceId(resourceId: String): FolderEntity?
@Query("SELECT * FROM folders WHERE uri = :uri LIMIT 1")
suspend fun getByUri(uri: String): FolderEntity?
@Upsert
suspend fun upsert(folder: FolderEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(folders: List<FolderEntity>)
@Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId")
suspend fun markMissing(resourceId: String, updatedAt: Long)
@Query("DELETE FROM folders WHERE resource_id = :resourceId")
suspend fun remove(resourceId: String)
}
@@ -0,0 +1,27 @@
package com.vaultdrop.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
@Dao
interface UserPreferenceDao {
@Query("SELECT * FROM user_preferences WHERE `key` = :key LIMIT 1")
suspend fun get(key: String): UserPreferenceEntity?
@Query("SELECT value FROM user_preferences WHERE `key` = :key LIMIT 1")
suspend fun getValue(key: String): String?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertIgnore(pref: UserPreferenceEntity)
@Upsert
suspend fun upsert(pref: UserPreferenceEntity)
@Query("DELETE FROM user_preferences WHERE `key` = :key")
suspend fun delete(key: String)
}
@@ -0,0 +1,54 @@
package com.vaultdrop.mobile.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Miroir de `FolderRow` (mobile/services/db/types.ts).
*
* `uri` est NULL pour les dossiers cloud-only ; l'index unique est posé sur la
* colonne directement (SQLite traite les NULL comme distincts, donc plusieurs
* lignes sans uri cohabitent — équivalent sémantique de l'index partiel
* `WHERE uri IS NOT NULL` du schéma JS).
*
* Statuts : 'local' | 'cloud' | 'local-cloud' (placement).
*/
@Entity(
tableName = "folders",
indices = [
Index(value = ["resource_id"], unique = true),
Index(value = ["uri"], unique = true),
Index(value = ["parent_resource_id"]),
],
)
data class FolderEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
@ColumnInfo(name = "resource_id")
val resourceId: String,
@ColumnInfo(name = "uri")
val uri: String? = null,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "exists")
val exists: Int? = null,
@ColumnInfo(name = "parent_resource_id")
val parentResourceId: String? = null,
@ColumnInfo(name = "owner_id")
val ownerId: String? = null,
@ColumnInfo(name = "sync_status")
val syncStatus: String = "local",
@ColumnInfo(name = "added_at")
val addedAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)
/** Placement d'un dossier — miroir de `SyncStatus` JS. */
object FolderStatus {
const val LOCAL = "local"
const val CLOUD = "cloud"
const val LOCAL_CLOUD = "local-cloud"
}
@@ -0,0 +1,21 @@
package com.vaultdrop.mobile.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Miroir de la table `user_preferences` JS (`key`/`value`/`updated_at`).
* Clés connues : `device_user_id` (32-hex, généré une fois), `sync_mode`,
* `active_user_id` (miroir non-sensible du compte connecté).
*/
@Entity(tableName = "user_preferences")
data class UserPreferenceEntity(
@PrimaryKey
@ColumnInfo(name = "key")
val key: String,
@ColumnInfo(name = "value")
val value: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)
@@ -0,0 +1,31 @@
package com.vaultdrop.mobile.data.local.migration
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Versions Room du schéma Kotlin.
*
* v1 : table `folders` (créée par Room à partir de l'entité).
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
* du compte actif et les préférences utilisateur.
*/
object Migrations {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `user_preferences` (
`key` TEXT NOT NULL,
`value` TEXT NOT NULL,
`updated_at` INTEGER NOT NULL,
PRIMARY KEY(`key`)
)
""".trimIndent(),
)
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
}
@@ -0,0 +1,101 @@
package com.vaultdrop.mobile.data.remote
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import okio.IOException
import retrofit2.Response
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Erreur normalisée du client — équivalent de `ApiError` JS.
* `code` = identifiant contractuel (`NETWORK_ERROR`, `HTTP_<status>`,
* `INVALID_RESPONSE`, ou code du body serveur).
*/
class ApiException(
val code: String,
override val message: String,
val httpCode: Int,
) : Exception(message)
/**
* Déballage de l'enveloppe API. Miroir de `request()` dans `api/client.ts` :
* - échec transport → `NETWORK_ERROR` ;
* - non-2xx → `ApiException` (code du body si présent, sinon `HTTP_<status>`),
* et un 401 sur un endpoint protégé déclenche `onUnauthorized` (sauf si
* `skipUnauthorizedHandling` — cas du login, où 401 = identifiants erronés) ;
* - 2xx sans `data` (ou corps illisible) → `INVALID_RESPONSE`.
*/
@Singleton
class ApiClient @Inject constructor(
private val apiService: ApiService,
moshi: Moshi,
) {
private val errorEnvelopeAdapter: JsonAdapter<ApiErrorEnvelope> =
moshi.adapter(ApiErrorEnvelope::class.java)
/** Callback 401 (expiration/révocation) — branché par AuthViewModel. */
@Volatile
var onUnauthorized: (() -> Unit)? = null
suspend fun listFolders(): List<FolderDto> = unwrap({ apiService.listFolders() })
suspend fun registerDevice(deviceId: String): String =
unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId
suspend fun login(username: String, password: String, deviceId: String): LoginResponseDto =
unwrap({ apiService.login(LoginRequestDto(username, password, deviceId)) },
skipUnauthorizedHandling = true)
private suspend fun <T> unwrap(
call: suspend () -> Response<ApiEnvelope<T>>,
skipUnauthorizedHandling: Boolean = false,
): T {
val response = try {
call()
} catch (e: IOException) {
Timber.d(e, "api: transport error")
throw ApiException("NETWORK_ERROR", "Serveur injoignable", 0)
} catch (e: Exception) {
Timber.d(e, "api: unexpected error")
throw ApiException("NETWORK_ERROR", "Serveur injoignable", 0)
}
if (!response.isSuccessful) {
val status = response.code()
if (status == 401 && !skipUnauthorizedHandling) {
onUnauthorized?.invoke()
}
val error = response.errorBody()?.use { body ->
body.string().let { raw ->
runCatching { errorEnvelopeAdapter.fromJson(raw)?.error }.getOrNull()
}
}
throw ApiException(
code = error?.code ?: "HTTP_$status",
message = error?.message ?: "HTTP $status",
httpCode = status,
)
}
val envelope = response.body()
val data = envelope?.data
if (data == null) {
Timber.w("api: 2xx sans enveloppe {data} (%s)", response.code())
throw ApiException(
code = "INVALID_RESPONSE",
message = "Réponse serveur invalide (enveloppe {data} attendue)",
httpCode = response.code(),
)
}
return data
}
}
@@ -0,0 +1,34 @@
package com.vaultdrop.mobile.data.remote
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
/**
* Contrat HTTP V1 — copie de `mobile/api/client.ts` (le client mobile est la
* source de vérité). Les méthodes renvoient `Response<ApiEnvelope<T>>` pour
* que l'`ApiClient` puisse dissocier succès / erreur contractuelle.
*/
interface ApiService {
@GET("files/folders")
suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>>
/** Enregistrement idempotent du device — aucun token émis. */
@POST("devices")
suspend fun registerDevice(
@Body body: DeviceRegistrationDto,
): Response<ApiEnvelope<DeviceRegistrationDto>>
/** Seule porte d'émission de token (V1). 401 = mauvaises identifiants. */
@POST("auth/login")
suspend fun login(
@Body body: LoginRequestDto,
): Response<ApiEnvelope<LoginResponseDto>>
}
@@ -0,0 +1,27 @@
package com.vaultdrop.mobile.data.remote
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
import com.vaultdrop.mobile.auth.TokenProvider
/**
* Injecte `Authorization: Bearer <token>` sur chaque requête si un token est
* disponible. Sans token (mode local / non connecté), aucune entête n'est posée
* — le serveur répondra 401, géré proprement par `ApiClient`.
*/
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenProvider: TokenProvider,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.current
val requestBuilder = chain.request().newBuilder()
if (token != null) {
requestBuilder.header("Authorization", "Bearer $token")
}
return chain.proceed(requestBuilder.build())
}
}
@@ -0,0 +1,71 @@
package com.vaultdrop.mobile.data.remote.dto
import com.squareup.moshi.Json
/**
* Enveloppe API V1 : succès `{ data, meta? }`, erreur `{ error }`.
* Les deux champs étant optionnels, Moshi parse les deux formes dans le même
* type ; c'est l'`ApiClient` qui décide selon le statut HTTP.
*/
data class ApiEnvelope<T>(
@Json(name = "data") val data: T? = null,
@Json(name = "meta") val meta: ApiMeta? = null,
@Json(name = "error") val error: ApiErrorBody? = null,
)
data class ApiMeta(
@Json(name = "page") val page: Int,
@Json(name = "pageSize") val pageSize: Int,
@Json(name = "total") val total: Int,
)
/**
* Corps d'erreur normalize — miroir de `ApiErrorBody` JS.
* Le `code` est l'identifiant contractuel (`UNAUTHORIZED`…), jamais le statut HTTP.
*/
data class ApiErrorBody(
@Json(name = "code") val code: String,
@Json(name = "message") val message: String,
)
/**
* Enveloppe d'erreur du body HTTP (utilisée sur les codes non-2xx, où Retrofit
* expose le corps brut via `errorBody()` sans le convertir).
*/
data class ApiErrorEnvelope(
@Json(name = "error") val error: ApiErrorBody? = null,
)
/** Miroir de `api/types.ts` — `FolderDto`. */
data class FolderDto(
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "parentId") val parentId: String? = null,
)
/** Miroir de `api/types.ts` — `DeviceRegistration`. */
data class DeviceRegistrationDto(
@Json(name = "deviceId") val deviceId: String,
)
/** Miroir de `api/types.ts` — `User`. */
data class UserDto(
@Json(name = "id") val id: String,
@Json(name = "username") val username: String,
@Json(name = "is_admin") val isAdmin: Boolean = false,
)
/** Miroir de `api/types.ts` — `LoginRequest`. */
data class LoginRequestDto(
@Json(name = "username") val username: String,
@Json(name = "password") val password: String,
@Json(name = "device_id") val deviceId: String,
)
/** Miroir de `api/types.ts` — `LoginResponse`. */
data class LoginResponseDto(
@Json(name = "token") val token: String,
@Json(name = "expires_at") val expiresAt: Long,
@Json(name = "user") val user: UserDto,
)
@@ -0,0 +1,36 @@
package com.vaultdrop.mobile.data.repository
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import com.vaultdrop.mobile.domain.DeviceIdentity
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Flux d'auth. Le device s'enregistre d'abord (`POST /devices`, idempotent,
* aucun token émis), puis `POST /auth/login` émet le paseto.
*/
@Singleton
class AuthRepository @Inject constructor(
private val apiClient: ApiClient,
private val deviceIdentity: DeviceIdentity,
) {
/** Enregistrement best-effort du device (offline → ignoré). */
suspend fun registerDevice(): String {
val deviceId = deviceIdentity.getOrCreate()
runCatching { apiClient.registerDevice(deviceId) }
.onFailure { Timber.d(it, "auth: device registration failed (offline?)") }
return deviceId
}
suspend fun login(username: String, password: String): LoginResponseDto {
val deviceId = deviceIdentity.getOrCreate()
// Re-registration idempotente juste avant le login : un device inconnu
// du serveur (ex. restart de la base) répondrait INVALID_DEVICE_ID.
runCatching { apiClient.registerDevice(deviceId) }
.onFailure { Timber.d(it, "auth: pre-login registration failed") }
return apiClient.login(username.trim(), password, deviceId)
}
}
@@ -0,0 +1,91 @@
package com.vaultdrop.mobile.data.repository
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.local.entity.FolderStatus
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.domain.GenerateId
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Miroir de `services/db/repositories/folders.ts`. Local-first : l'UI lit Room
* ; le réseau sert de source de rafraîchissement (snapshot cloud).
*/
@Singleton
class FolderRepository @Inject constructor(
private val folderDao: FolderDao,
private val apiClient: ApiClient,
private val generateId: GenerateId,
) {
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
suspend fun getFolder(resourceId: String): FolderEntity? =
folderDao.getByResourceId(resourceId)
/**
* Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud
* n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie
* que les racines.
*/
suspend fun refreshFromServer() {
val folders = apiClient.listFolders()
if (folders.isEmpty()) return
val now = System.currentTimeMillis()
folderDao.upsertAll(folders.map { toEntity(it, now) })
}
private suspend fun toEntity(dto: FolderDto, now: Long): FolderEntity {
val existing = folderDao.getByResourceId(dto.id)
return FolderEntity(
resourceId = dto.id,
uri = null,
name = dto.name,
exists = null,
parentResourceId = dto.parentId,
ownerId = existing?.ownerId,
syncStatus = existing?.syncStatus ?: FolderStatus.CLOUD,
addedAt = existing?.addedAt ?: now,
updatedAt = now,
)
}
/**
* Upsert local d'un dossier physique (SAF) — établi par UX volontairement :
* ajoute une ligne avec sa `uri` si absente, ou met à jour ses champs.
*/
suspend fun saveFolder(
input: SaveFolderInput,
parentResourceId: String? = null,
): FolderEntity {
val now = System.currentTimeMillis()
val existing = input.resourceId?.let { folderDao.getByResourceId(it) }
?: input.uri?.let { folderDao.getByUri(it) }
val entity = FolderEntity(
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
uri = input.uri,
name = input.name,
exists = input.exists?.let { if (it) 1 else 0 },
parentResourceId = existing?.parentResourceId ?: parentResourceId,
ownerId = existing?.ownerId,
syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL,
addedAt = existing?.addedAt ?: now,
updatedAt = now,
)
folderDao.upsert(entity)
return entity
}
}
data class SaveFolderInput(
val uri: String?,
val name: String,
val exists: Boolean? = null,
val resourceId: String? = null,
)
@@ -0,0 +1,12 @@
package com.vaultdrop.mobile.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Regroupe les bindings transverses (Domain, Ids générés, etc.).
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule
@@ -0,0 +1,32 @@
package com.vaultdrop.mobile.di
import android.content.Context
import androidx.room.Room
import com.vaultdrop.mobile.data.local.AppDatabase
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.migration.Migrations
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "dot.db")
.addMigrations(*Migrations.ALL)
.build()
@Provides
fun provideFolderDao(db: AppDatabase): FolderDao = db.folderDao()
@Provides
fun provideUserPreferenceDao(db: AppDatabase): UserPreferenceDao = db.userPreferenceDao()
}
@@ -0,0 +1,60 @@
package com.vaultdrop.mobile.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.vaultdrop.mobile.BuildConfig
import com.vaultdrop.mobile.data.remote.ApiService
import com.vaultdrop.mobile.data.remote.AuthInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BASIC
} else {
HttpLoggingInterceptor.Level.NONE
}
}
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL.trimEnd('/') + "/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}
@@ -0,0 +1,26 @@
package com.vaultdrop.mobile.domain
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
import javax.inject.Inject
import javax.inject.Singleton
/**
* Miroir non-sensible du compte connecté (le token vit dans le store sécurisé).
* Permet aux repositories de scoper leurs lectures sans dépendre du store chiffré.
*/
@Singleton
class ActiveUserStore @Inject constructor(
private val userPreferenceDao: UserPreferenceDao,
) {
suspend fun set(userId: String) {
userPreferenceDao.upsert(
UserPreferenceEntity(PrefKeys.ACTIVE_USER_ID, userId, System.currentTimeMillis()),
)
}
suspend fun get(): String? = userPreferenceDao.getValue(PrefKeys.ACTIVE_USER_ID)
suspend fun clear() = userPreferenceDao.delete(PrefKeys.ACTIVE_USER_ID)
}
@@ -0,0 +1,27 @@
package com.vaultdrop.mobile.domain
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identité locale du device : 32-hex généré une seule fois à la première
* requête, persisté en `user_preferences`. Équivalent de `getDeviceUserId()`.
*/
@Singleton
class DeviceIdentity @Inject constructor(
private val userPreferenceDao: UserPreferenceDao,
private val generateId: GenerateId,
) {
suspend fun getOrCreate(): String {
userPreferenceDao.getValue(PrefKeys.DEVICE_USER_ID)?.let { return it }
val id = generateId.newResourceId()
userPreferenceDao.insertIgnore(
UserPreferenceEntity(PrefKeys.DEVICE_USER_ID, id, System.currentTimeMillis()),
)
return userPreferenceDao.getValue(PrefKeys.DEVICE_USER_ID)
?: generateId.newResourceId()
}
}
@@ -0,0 +1,21 @@
package com.vaultdrop.mobile.domain
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identifiants opaques 32-hex minuscule — équivalent de `newResourceId()` JS
* (`lower(hex(randomblob(16)))`). Jamais reconverti en UUID côté serveur.
*/
@Singleton
class GenerateId @Inject constructor() {
private val random = SecureRandom()
fun newResourceId(): String {
val bytes = ByteArray(16)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,8 @@
package com.vaultdrop.mobile.domain
/** Clés de `user_preferences` — miroir de `services/db/schema.ts`. */
object PrefKeys {
const val DEVICE_USER_ID = "device_user_id"
const val ACTIVE_USER_ID = "active_user_id"
const val SYNC_MODE = "sync_mode"
}
@@ -0,0 +1,16 @@
package com.vaultdrop.mobile.ui.auth
import com.vaultdrop.mobile.data.remote.dto.UserDto
/** État global de connexion — équivalent de `AuthStatus` JS. */
sealed interface AuthState {
data object Loading : AuthState
data object SignedOut : AuthState
data object Local : AuthState
data class SignedIn(val user: UserDto) : AuthState
}
data class LoginUiState(
val isSubmitting: Boolean = false,
val error: String? = null,
)
@@ -0,0 +1,86 @@
package com.vaultdrop.mobile.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.auth.SessionManager
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val sessionManager: SessionManager,
private val apiClient: ApiClient,
) : ViewModel() {
private val _authState = MutableStateFlow<AuthState>(AuthState.Loading)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
private val _loginUiState = MutableStateFlow(LoginUiState())
val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()
init {
// 401 sur un endpoint protégé (token expiré/révoqué, compte supprimé)
// → purge de la session. Le 401 du login est exonéré (skip dans ApiClient).
apiClient.onUnauthorized = { signOut() }
viewModelScope.launch {
val session = sessionManager.restore()
_authState.value =
if (session != null) AuthState.SignedIn(session.user) else AuthState.SignedOut
authRepository.registerDevice()
}
}
fun signIn(username: String, password: String) {
if (username.isBlank() || password.isBlank()) {
_loginUiState.update { it.copy(error = "REQUIRED") }
return
}
viewModelScope.launch {
_loginUiState.update { LoginUiState(isSubmitting = true) }
runCatching { authRepository.login(username, password) }
.onSuccess { response ->
sessionManager.save(response.token, response.user)
_loginUiState.value = LoginUiState()
_authState.value = AuthState.SignedIn(response.user)
}
.onFailure { e ->
val error = when (e) {
is ApiException -> e.code
else -> "GENERIC"
}
_loginUiState.update { it.copy(isSubmitting = false, error = error) }
}
}
}
fun continueWithoutAccount() {
_authState.value = AuthState.Local
}
fun signOut() {
viewModelScope.launch {
Timber.d("auth: session purgée (signOut)")
withContext(Dispatchers.IO) {
sessionManager.clear()
}
_authState.value = AuthState.SignedOut
}
}
override fun onCleared() {
apiClient.onUnauthorized = null
}
}
@@ -0,0 +1,135 @@
package com.vaultdrop.mobile.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
@Composable
fun LoginScreen(
viewModel: AuthViewModel = hiltViewModel(),
) {
val loginUi by viewModel.loginUiState.collectAsStateWithLifecycle()
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.login_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.login_username)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.signIn(username, password) },
enabled = !loginUi.isSubmitting,
modifier = Modifier.fillMaxWidth(),
) {
if (loginUi.isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Text(stringResource(R.string.login_submit))
}
}
loginUi.error?.let { error ->
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(errorResFor(error)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = viewModel::continueWithoutAccount) {
Text(stringResource(R.string.login_skip))
}
Text(
text = stringResource(R.string.skip_message),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
private fun errorResFor(code: String): Int = when (code) {
"REQUIRED" -> R.string.login_error_required
"UNAUTHORIZED" -> R.string.login_error_unauthorized
else -> R.string.login_error_generic
}
@@ -0,0 +1,141 @@
package com.vaultdrop.mobile.ui.folderlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FolderEntity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FolderListScreen(
viewModel: FolderListViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.files)) },
actions = {
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
}
},
)
},
) { padding ->
FolderListContent(
uiState = uiState,
modifier = Modifier.padding(padding),
)
}
}
@Composable
private fun FolderListContent(
uiState: FolderListUiState,
modifier: Modifier = Modifier,
) {
when {
uiState.folders.isEmpty() && uiState.isRefreshing -> {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
uiState.folders.isEmpty() -> {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.no_folders_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(uiState.folders, key = { it.resourceId }) { folder ->
FolderRow(folder)
}
}
}
}
}
@Composable
private fun FolderRow(folder: FolderEntity) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Text(
text = folder.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium,
)
}
}
}
@@ -0,0 +1,9 @@
package com.vaultdrop.mobile.ui.folderlist
import com.vaultdrop.mobile.data.local.entity.FolderEntity
data class FolderListUiState(
val folders: List<FolderEntity> = emptyList(),
val isRefreshing: Boolean = false,
val error: String? = null,
)
@@ -0,0 +1,58 @@
package com.vaultdrop.mobile.ui.folderlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.auth.TokenProvider
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.FolderRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class FolderListViewModel @Inject constructor(
private val folderRepository: FolderRepository,
private val tokenProvider: TokenProvider,
) : ViewModel() {
private val _uiState = MutableStateFlow(FolderListUiState())
val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow()
init {
observeFolders()
refresh()
}
private fun observeFolders() {
viewModelScope.launch {
folderRepository.observeRootFolders().collect { folders ->
_uiState.update { it.copy(folders = folders) }
}
}
}
fun refresh() {
viewModelScope.launch {
// Sans token → mode local : on ne tient pas de GET /files/folders.
if (tokenProvider.current == null) {
Timber.d("folders: pas de token, rafraîchissement serveur ignoré")
return@launch
}
_uiState.update { it.copy(isRefreshing = true, error = null) }
runCatching { folderRepository.refreshFromServer() }
.onFailure { e ->
val error = when {
e is ApiException && e.code == "UNAUTHORIZED" -> "AUTH_REQUIRED"
else -> e.message ?: "Erreur réseau"
}
_uiState.update { it.copy(error = error) }
}
_uiState.update { it.copy(isRefreshing = false) }
}
}
}
@@ -0,0 +1,24 @@
package com.vaultdrop.mobile.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
object Routes {
const val FILES = "files"
}
@Composable
fun NavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.FILES,
) {
composable(Routes.FILES) {
FolderListScreen()
}
}
}
@@ -0,0 +1,44 @@
package com.vaultdrop.mobile.ui.navigation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.ui.auth.AuthState
import com.vaultdrop.mobile.ui.auth.AuthViewModel
import com.vaultdrop.mobile.ui.auth.LoginScreen
@Composable
private fun SplashScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
/**
* Gate racine : restaure la session puis oriente (login si signé out,
* navigation principale si signé in / mode local).
*/
@Composable
fun VaultDropApp(
authViewModel: AuthViewModel = hiltViewModel(),
) {
val authState by authViewModel.authState.collectAsStateWithLifecycle()
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.SignedOut -> LoginScreen()
AuthState.Local -> NavGraph()
is AuthState.SignedIn -> NavGraph()
}
}
@@ -0,0 +1,9 @@
package com.vaultdrop.mobile.ui.theme
import androidx.compose.ui.graphics.Color
val BrandBlue = Color(0xFF1A73E8)
val BondBlueLight = Color(0xFFD2E3FC)
val InactiveGray = Color(0xFF6B7280)
val CardBackground = Color(0xFFF7F8FA)
val CardBorder = Color(0xFFEAEAEA)
@@ -0,0 +1,34 @@
package com.vaultdrop.mobile.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = BrandBlue,
onPrimary = androidx.compose.ui.graphics.Color.White,
secondary = InactiveGray,
surfaceVariant = CardBackground,
)
private val DarkColors = darkColorScheme(
primary = BondBlueLight,
onPrimary = androidx.compose.ui.graphics.Color.Black,
)
@Composable
fun VaultDropTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = Typography,
content = content,
)
}
@Composable
private fun isSystemInDarkTheme(): Boolean =
androidx.compose.foundation.isSystemInDarkTheme()
@@ -0,0 +1,24 @@
package com.vaultdrop.mobile.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontSize = 16.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.Normal,
),
titleMedium = TextStyle(
fontSize = 16.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.SemiBold,
),
labelMedium = TextStyle(
fontSize = 12.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.Medium,
),
)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1a73e8"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M33,30h24l8,8h10a4,4 0 0 1 4,4v32a4,4 0 0 1 -4,4h-42a4,4 0 0 1 -4,-4v-40a4,4 0 0 1 4,-4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M36,44h24v4h-24zM36,52h36v4h-36zM36,60h28v4h-28z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,25 @@
<resources>
<string name="app_name">VaultDrop</string>
<string name="files">Files</string>
<string name="folder">Folder</string>
<string name="add_folder">Add a folder</string>
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
<string name="empty_folder">Empty folder — next sync will refresh it.</string>
<string name="error_loading">Could not load folders</string>
<string name="refresh">Refresh</string>
<!-- Login -->
<string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in to your account</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_submit">Sign in</string>
<string name="login_submitting">Signing in…</string>
<string name="login_error_required">Username and password are required</string>
<string name="login_error_generic">Could not sign in right now</string>
<string name="login_error_unauthorized">Incorrect username or password</string>
<string name="login_skip">Continue without account</string>
<string name="skip_message">Local mode — your folders stay on this device and are not synced.</string>
</resources>
@@ -0,0 +1,26 @@
<resources>
<string name="app_name">VaultDrop</string>
<!-- Écran Fichiers -->
<string name="files">Fichiers</string>
<string name="folder">Dossier</string>
<string name="add_folder">Ajouter un dossier</string>
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>
<string name="empty_folder">Dossier vide — le prochain syncDevice l\'actualisera.</string>
<string name="error_loading">Impossible de charger les dossiers</string>
<string name="refresh">Actualiser</string>
<!-- Login -->
<string name="login_title">Connexion</string>
<string name="login_subtitle">Connecte-toi à ton compte</string>
<string name="login_username">Nom d\'utilisateur</string>
<string name="login_password">Mot de passe</string>
<string name="login_submit">Se connecter</string>
<string name="login_submitting">Connexion…</string>
<string name="login_error_required">Nom d\'utilisateur et mot de passe requis</string>
<string name="login_error_generic">Impossible de se connecter pour le moment</string>
<string name="login_error_unauthorized">Nom d\'utilisateur ou mot de passe incorrect</string>
<string name="login_skip">Continuer sans compte</string>
<string name="skip_message">Mode local — tes dossiers restent sur cet appareil et ne sont pas synchronisés.</string>
</resources>
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.VaultDrop" parent="android:Theme.Material.Light.NoActionBar" />
</resources>